diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/components/empty-section.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/components/empty-section.tsx new file mode 100644 index 0000000000..7248ef446f --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/components/empty-section.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { Link4 } from "@unkey/icons"; +import { Empty } from "@unkey/ui"; +import type { PropsWithChildren, ReactNode } from "react"; + +type EmptySectionProps = PropsWithChildren<{ + title: string; + description: string; + icon?: ReactNode; + className?: string; +}>; + +export const EmptySection = ({ + title, + description, + children, + icon = , + className, +}: EmptySectionProps) => ( + + {icon} + {title} + {description} + {children && {children}} + +); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/data-provider.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/data-provider.tsx new file mode 100644 index 0000000000..cfbd8a4d06 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/data-provider.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { collection } from "@/lib/collections"; +import type { Deployment } from "@/lib/collections/deploy/deployments"; +import type { Domain } from "@/lib/collections/deploy/domains"; +import type { Environment } from "@/lib/collections/deploy/environments"; +import type { Project } from "@/lib/collections/deploy/projects"; +import { eq, useLiveQuery } from "@tanstack/react-db"; +import { useParams } from "next/navigation"; +import { type PropsWithChildren, createContext, useContext, useMemo } from "react"; + +type ProjectDataContextType = { + projectId: string; + + project: Project | undefined; + isProjectLoading: boolean; + + domains: Domain[]; + deployments: Deployment[]; + environments: Environment[]; + + isDomainsLoading: boolean; + isDeploymentsLoading: boolean; + isEnvironmentsLoading: boolean; + + getDomainsForDeployment: (deploymentId: string) => Domain[]; + getLiveDomains: () => Domain[]; + getEnvironmentOrLiveDomains: () => Domain[]; + getDeploymentById: (id: string) => Deployment | undefined; + + refetchDomains: () => void; + refetchDeployments: () => void; + refetchAll: () => void; +}; + +const ProjectDataContext = createContext(null); + +export const ProjectDataProvider = ({ children }: PropsWithChildren) => { + const params = useParams(); + const projectId = params?.projectId; + + if (!projectId || typeof projectId !== "string") { + throw new Error("ProjectDataProvider must be used within a project route"); + } + + const domainsQuery = useLiveQuery( + (q) => + q + .from({ domain: collection.domains }) + .where(({ domain }) => eq(domain.projectId, projectId)) + .orderBy(({ domain }) => domain.createdAt, "desc"), + [projectId], + ); + + const deploymentsQuery = useLiveQuery( + (q) => + q + .from({ deployment: collection.deployments }) + .where(({ deployment }) => eq(deployment.projectId, projectId)) + .orderBy(({ deployment }) => deployment.createdAt, "desc"), + [projectId], + ); + + const projectQuery = useLiveQuery( + (q) => + q.from({ project: collection.projects }).where(({ project }) => eq(project.id, projectId)), + [projectId], + ); + + const environmentsQuery = useLiveQuery( + (q) => + q.from({ env: collection.environments }).where(({ env }) => eq(env.projectId, projectId)), + [projectId], + ); + + const value = useMemo(() => { + const domains = domainsQuery.data ?? []; + const deployments = deploymentsQuery.data ?? []; + const environments = environmentsQuery.data ?? []; + const project = projectQuery.data?.at(0); + + return { + projectId, + + project, + isProjectLoading: projectQuery.isLoading, + + domains, + isDomainsLoading: domainsQuery.isLoading, + + deployments, + isDeploymentsLoading: deploymentsQuery.isLoading, + + environments, + isEnvironmentsLoading: environmentsQuery.isLoading, + + getDomainsForDeployment: (deploymentId: string) => + domains.filter((d) => d.deploymentId === deploymentId), + + getLiveDomains: () => domains.filter((d) => d.sticky === "live"), + + getEnvironmentOrLiveDomains: () => + domains.filter((d) => d.sticky === "environment" || d.sticky === "live"), + + getDeploymentById: (id: string) => deployments.find((d) => d.id === id), + + refetchDomains: () => collection.domains.utils.refetch(), + refetchDeployments: () => collection.deployments.utils.refetch(), + refetchAll: () => { + collection.projects.utils.refetch(); + collection.deployments.utils.refetch(); + collection.domains.utils.refetch(); + collection.environments.utils.refetch(); + }, + }; + }, [projectId, domainsQuery, deploymentsQuery, projectQuery, environmentsQuery]); + + return {children}; +}; + +export const useProjectData = () => { + const context = useContext(ProjectDataContext); + if (!context) { + throw new Error("useProjectData must be used within ProjectDataProvider"); + } + return context; +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/scroll-to-bottom-button.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/scroll-to-bottom-button.tsx new file mode 100644 index 0000000000..f2bd90ea16 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/scroll-to-bottom-button.tsx @@ -0,0 +1,47 @@ +"use client"; +import { ChevronDown } from "@unkey/icons"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { useProjectData } from "../../../../data-provider"; +import { useDeployment } from "../../layout-provider"; + +export function ScrollToBottomButton() { + const { deploymentId } = useDeployment(); + const { getDeploymentById } = useProjectData(); + + const deployment = getDeploymentById(deploymentId); + const isVisible = deployment?.status !== "ready"; + + const handleScrollToBottom = () => { + const container = document.getElementById("deployment-scroll-container"); + if (container) { + container.scrollTo({ + top: container.scrollHeight, + behavior: "smooth", + }); + } + }; + + return ( + + ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/sections/deployment-domains-section.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/sections/deployment-domains-section.tsx index 35c686590a..1472268dc0 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/sections/deployment-domains-section.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/sections/deployment-domains-section.tsx @@ -1,26 +1,16 @@ "use client"; -import { eq, useLiveQuery } from "@tanstack/react-db"; import { Earth } from "@unkey/icons"; -import { useParams } from "next/navigation"; import { Section, SectionHeader } from "../../../../../../components/section"; -import { DomainRow, DomainRowEmpty, DomainRowSkeleton } from "../../../../../details/domain-row"; -import { useProject } from "../../../../../layout-provider"; +import { EmptySection } from "../../../../../components/empty-section"; +import { useProjectData } from "../../../../../data-provider"; +import { DomainRow, DomainRowSkeleton } from "../../../../../details/domain-row"; +import { useDeployment } from "../../../layout-provider"; export function DeploymentDomainsSection() { - const params = useParams(); - const deploymentId = params?.deploymentId as string; - - const { collections } = useProject(); - - const { data: domains, isLoading } = useLiveQuery( - (q) => - q - .from({ domain: collections.domains }) - .where(({ domain }) => eq(domain.deploymentId, deploymentId)), - [deploymentId], - ); - + const { deploymentId } = useDeployment(); + const { getDomainsForDeployment, isDomainsLoading } = useProjectData(); + const domains = getDomainsForDeployment(deploymentId); return (
- {isLoading ? ( + {isDomainsLoading ? ( <> - ) : (domains?.length ?? 0) > 0 ? ( - domains?.map((domain) => ( + ) : domains.length > 0 ? ( + domains.map((domain) => ( )) ) : ( - + )}
diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/sections/deployment-info-section.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/sections/deployment-info-section.tsx index 953eac5ee0..07a230e3bf 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/sections/deployment-info-section.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/sections/deployment-info-section.tsx @@ -1,31 +1,23 @@ "use client"; -import { eq, useLiveQuery } from "@tanstack/react-db"; import { Bolt, Cloud, Grid, Harddrive, LayoutRight } from "@unkey/icons"; import { Button, InfoTooltip } from "@unkey/ui"; -import { useParams } from "next/navigation"; import { ActiveDeploymentCard } from "../../../../../../components/active-deployment-card"; import { DeploymentStatusBadge } from "../../../../../../components/deployment-status-badge"; import { DisabledWrapper } from "../../../../../../components/disabled-wrapper"; import { InfoChip } from "../../../../../../components/info-chip"; import { RegionFlags } from "../../../../../../components/region-flags"; import { Section, SectionHeader } from "../../../../../../components/section"; -import { useProject } from "../../../../../layout-provider"; +import { useProjectData } from "../../../../../data-provider"; +import { useProjectLayout } from "../../../../../layout-provider"; +import { useDeployment } from "../../../layout-provider"; export function DeploymentInfoSection() { - const params = useParams(); - const deploymentId = params?.deploymentId as string; + const { deploymentId } = useDeployment(); + const { getDeploymentById } = useProjectData(); + const { setIsDetailsOpen, isDetailsOpen } = useProjectLayout(); - const { collections, setIsDetailsOpen, isDetailsOpen } = useProject(); - const { data } = useLiveQuery( - (q) => - q - .from({ deployment: collections.deployments }) - .where(({ deployment }) => eq(deployment.id, deploymentId)), - [deploymentId], - ); - - const deployment = data.at(0); + const deployment = getDeploymentById(deploymentId); const deploymentStatus = deployment?.status; return ( diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/sections/deployment-logs-section.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/sections/deployment-logs-section.tsx index 63f215ec76..c5454ad645 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/sections/deployment-logs-section.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/sections/deployment-logs-section.tsx @@ -1,40 +1,30 @@ "use client"; -import { eq, useLiveQuery } from "@tanstack/react-db"; import { Layers3 } from "@unkey/icons"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@unkey/ui"; -import { useParams } from "next/navigation"; import { useEffect, useState } from "react"; import { Section } from "../../../../../../components/section"; import { Card } from "../../../../../components/card"; -import { useProject } from "../../../../../layout-provider"; +import { useProjectData } from "../../../../../data-provider"; +import { useDeployment } from "../../../layout-provider"; import { DeploymentBuildStepsTable } from "../table/deployment-build-steps-table"; import { DeploymentSentinelLogsTable } from "../table/deployment-sentinel-logs-table"; export function DeploymentLogsSection() { - const params = useParams(); - const deploymentId = params?.deploymentId as string; + const { deploymentId } = useDeployment(); + const { getDeploymentById } = useProjectData(); - const { collections } = useProject(); - const { data } = useLiveQuery( - (q) => - q - .from({ deployment: collections.deployments }) - .where(({ deployment }) => eq(deployment.id, deploymentId)), - [deploymentId], - ); - - const deployment = data.at(0); + const deployment = getDeploymentById(deploymentId); const deploymentStatus = deployment?.status; // During build phase, default to "Build logs" and disable "Logs" tab - const isBuildPhase = deploymentStatus === "building"; + const isReady = deploymentStatus !== "ready"; - const [tab, setTab] = useState(isBuildPhase ? "build-logs" : "sentinel"); + const [tab, setTab] = useState(isReady ? "build-logs" : "requests"); useEffect(() => { - setTab(isBuildPhase ? "build-logs" : "sentinel"); - }, [isBuildPhase]); + setTab(isReady ? "build-logs" : "requests"); + }, [isReady]); return (
@@ -42,11 +32,7 @@ export function DeploymentLogsSection() {
- + Requests @@ -54,7 +40,7 @@ export function DeploymentLogsSection() {
- + diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/sections/deployment-network-section.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/sections/deployment-network-section.tsx index b6952af5cd..f227219f6f 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/sections/deployment-network-section.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/components/sections/deployment-network-section.tsx @@ -2,10 +2,10 @@ import type { PERCENTILE_VALUES } from "@unkey/clickhouse/src/sentinel"; import { ChartActivity, Layers2, TimeClock } from "@unkey/icons"; -import { useParams } from "next/navigation"; import { useState } from "react"; import { Section, SectionHeader } from "../../../../../../components/section"; import { Card } from "../../../../../components/card"; +import { useDeployment } from "../../../layout-provider"; import { DeploymentNetworkView } from "../../../network/deployment-network-view"; import { useDeploymentLatency } from "../../hooks/use-deployment-latency"; import { useDeploymentRps } from "../../hooks/use-deployment-rps"; @@ -14,9 +14,7 @@ import { MetricCard } from "../metrics/metric-card"; export function DeploymentNetworkSection() { const [latencyPercentile, setLatencyPercentile] = useState("p50"); - const params = useParams(); - const deploymentId = params?.deploymentId as string; - const projectId = params?.projectId as string; + const { deploymentId } = useDeployment(); const { currentRps, timeseries: rpsTimeseries } = useDeploymentRps(deploymentId); const { currentLatency, timeseries: latencyTimeseries } = useDeploymentLatency( @@ -32,7 +30,7 @@ export function DeploymentNetworkSection() { />
- +
- q - .from({ deployment: collections.deployments }) - .where(({ deployment }) => eq(deployment.id, deploymentId)), - [deploymentId], - ); - - const environmentId = deployment.data.at(0)?.environmentId ?? ""; + const deployment = getDeploymentById(deploymentId); + const environmentId = deployment?.environmentId ?? ""; const { data, isLoading, error } = trpc.deploy.sentinelLogs.query.useInfiniteQuery( { diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/navigations/use-deployment-breadcrumb-config.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/navigations/use-deployment-breadcrumb-config.ts index 65276b499d..3493f12791 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/navigations/use-deployment-breadcrumb-config.ts +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(overview)/navigations/use-deployment-breadcrumb-config.ts @@ -6,6 +6,8 @@ import { shortenId } from "@/lib/shorten-id"; import { useParams, useSelectedLayoutSegments } from "next/navigation"; import { useMemo } from "react"; import type { ComponentPropsWithoutRef } from "react"; +import { useProjectData } from "../../../../data-provider"; +import { useDeployment } from "../../layout-provider"; export type BreadcrumbItem = ComponentPropsWithoutRef & { /** Unique identifier for the breadcrumb item */ @@ -25,8 +27,8 @@ export function useDeploymentBreadcrumbConfig(): BreadcrumbItem[] { const segments = useSelectedLayoutSegments(); const workspaceSlug = params.workspaceSlug as string; - const projectId = params.projectId as string; - const deploymentId = params.deploymentId as string; + const { projectId } = useProjectData(); + const { deploymentId } = useDeployment(); // Detect current tab from segments const currentTab = segments.includes("network") diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/layout-provider.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/layout-provider.tsx new file mode 100644 index 0000000000..d936d4c668 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/layout-provider.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { createContext, useContext } from "react"; + +type DeploymentLayoutContextType = { + deploymentId: string; +}; + +const DeploymentLayoutContext = createContext(null); + +export const DeploymentLayoutProvider = ({ children }: { children: React.ReactNode }) => { + const params = useParams(); + const deploymentId = params?.deploymentId; + + if (!deploymentId || typeof deploymentId !== "string") { + throw new Error("DeploymentLayoutProvider must be used within a deployment route"); + } + + return ( + + {children} + + ); +}; + +export const useDeployment = () => { + const context = useContext(DeploymentLayoutContext); + if (!context) { + throw new Error("useDeployment must be used within a deployment route"); + } + return context; +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/layout.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/layout.tsx index 828fef9753..dabb21e830 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/layout.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/layout.tsx @@ -1,14 +1,16 @@ +import type { PropsWithChildren } from "react"; import { DeploymentNavbar } from "./(overview)/navigations/deployment-navbar"; +import { DeploymentLayoutProvider } from "./layout-provider"; -export default function DeploymentLayout({ - children, -}: { - children: React.ReactNode; -}) { +export default function DeploymentLayout({ children }: PropsWithChildren) { return ( -
- -
{children}
-
+ +
+ +
+ {children} +
+
+
); } diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/deployment-network-view.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/deployment-network-view.tsx index 4d6305efe7..fb1d07f8c3 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/deployment-network-view.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/deployment-network-view.tsx @@ -1,6 +1,7 @@ "use client"; import { trpc } from "@/lib/trpc/client"; import { useState } from "react"; +import { useDeployment } from "../layout-provider"; import { type DeploymentNode, InfiniteCanvas, @@ -22,18 +23,15 @@ import { } from "./unkey-flow"; interface DeploymentNetworkViewProps { - projectId: string; - deploymentId?: string | null; showProjectDetails?: boolean; showNodeDetails?: boolean; } export function DeploymentNetworkView({ - projectId, - deploymentId, showProjectDetails = false, showNodeDetails = false, }: DeploymentNetworkViewProps) { + const { deploymentId } = useDeployment(); const [generatedTree, setGeneratedTree] = useState(null); const [selectedNode, setSelectedNode] = useState(null); @@ -41,7 +39,7 @@ export function DeploymentNetworkView({ { deploymentId: deploymentId ?? "", }, - { enabled: Boolean(deploymentId) }, + { refetchInterval: 2000, enabled: Boolean(deploymentId) }, ); const currentTree = generatedTree ?? defaultTree ?? SKELETON_TREE; @@ -56,7 +54,7 @@ export function DeploymentNetworkView({ setSelectedNode(null)} /> )} - {showProjectDetails && } + {showProjectDetails && } {process.env.NODE_ENV === "development" && ( ; + return ; } diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/overlay/project-details.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/overlay/project-details.tsx index 230da625f5..3f0bd77eda 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/overlay/project-details.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/overlay/project-details.tsx @@ -6,11 +6,7 @@ import { ChevronDown, CodeBranch, CodeCommit } from "@unkey/icons"; import { cn } from "@unkey/ui/src/lib/utils"; import { useState } from "react"; -type ProjectDetailsProps = { - projectId: string; -}; - -export const ProjectDetails = ({ projectId }: ProjectDetailsProps) => { +export const ProjectDetails = () => { const [isOpen, setIsOpen] = useState(false); return ( @@ -49,7 +45,7 @@ export const ProjectDetails = ({ projectId }: ProjectDetailsProps) => { isOpen ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2 pointer-events-none", )} > - +
); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/page.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/page.tsx index 77a7659dbd..d0ac8a005c 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/page.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/page.tsx @@ -1,52 +1,21 @@ "use client"; - -import { eq, useLiveQuery } from "@tanstack/react-db"; -import { Cube } from "@unkey/icons"; -import { Card } from "@unkey/ui"; -import { useParams } from "next/navigation"; import { ProjectContentWrapper } from "../../../components/project-content-wrapper"; -import { Section, SectionHeader } from "../../../components/section"; -import { useProject } from "../../layout-provider"; +import { ScrollToBottomButton } from "./(overview)/components/scroll-to-bottom-button"; import { DeploymentDomainsSection } from "./(overview)/components/sections/deployment-domains-section"; import { DeploymentInfoSection } from "./(overview)/components/sections/deployment-info-section"; import { DeploymentLogsSection } from "./(overview)/components/sections/deployment-logs-section"; import { DeploymentNetworkSection } from "./(overview)/components/sections/deployment-network-section"; -import { DeploymentBuildStepsTable } from "./(overview)/components/table/deployment-build-steps-table"; export default function DeploymentOverview() { - const { collections } = useProject(); - - const params = useParams(); - const deploymentId = params?.deploymentId as string; - - const deployments = useLiveQuery( - (q) => - q - .from({ deployment: collections.deployments }) - .where(({ deployment }) => eq(deployment.id, deploymentId)), - [deploymentId], - ); - - const isReady = deployments.data?.at(0)?.status === "ready"; - return ( - - - {isReady ? : null} - {isReady ? : null} - {isReady ? ( + <> + + + + - ) : ( -
- } - title="Build Logs" - /> - - - -
- )} -
+
+ + ); } diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/controls/components/deployment-list-filters/components/environment-filter.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/controls/components/deployment-list-filters/components/environment-filter.tsx index 49dd22a3e1..618828fcc3 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/controls/components/deployment-list-filters/components/environment-filter.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/controls/components/deployment-list-filters/components/environment-filter.tsx @@ -1,17 +1,14 @@ -import { useProject } from "@/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/layout-provider"; import { FilterCheckbox } from "@/components/logs/checkbox/filter-checkbox"; -import { useLiveQuery } from "@tanstack/react-db"; +import { useProjectData } from "../../../../../../data-provider"; import { useFilters } from "../../../../../hooks/use-filters"; export const EnvironmentFilter = () => { const { filters, updateFilters } = useFilters(); - const { collections } = useProject(); - - const environments = useLiveQuery((q) => q.from({ environment: collections.environments })); + const { environments } = useProjectData(); return ( ({ + options={environments.map((environment, i) => ({ id: i, slug: environment.slug, }))} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/promotion-dialog.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/promotion-dialog.tsx index efe1f4dca5..d8ce239328 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/promotion-dialog.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/promotion-dialog.tsx @@ -1,12 +1,12 @@ "use client"; -import { type Deployment, collection, collectionManager } from "@/lib/collections"; +import type { Deployment } from "@/lib/collections"; import { shortenId } from "@/lib/shorten-id"; import { trpc } from "@/lib/trpc/client"; -import { eq, inArray, useLiveQuery } from "@tanstack/react-db"; import { CircleInfo, CodeBranch, CodeCommit, Link4 } from "@unkey/icons"; import { Badge, Button, DialogContainer, toast } from "@unkey/ui"; import { StatusIndicator } from "../../../components/status-indicator"; +import { useProjectData } from "../../data-provider"; type DeploymentSectionProps = { title: string; @@ -39,15 +39,11 @@ export const PromotionDialog = ({ liveDeployment, }: PromotionDialogProps) => { const utils = trpc.useUtils(); - const domainCollection = collectionManager.getProjectCollections( - liveDeployment.projectId, - ).domains; - const domains = useLiveQuery((q) => - q - .from({ domain: domainCollection }) - .where(({ domain }) => inArray(domain.sticky, ["environment", "live"])) - .where(({ domain }) => eq(domain.deploymentId, liveDeployment.id)), - ); + const { getEnvironmentOrLiveDomains, refetchAll } = useProjectData(); + + // Get domains for live deployment and filter for environment/live sticky domains + const domains = getEnvironmentOrLiveDomains().filter((d) => d.deploymentId === liveDeployment.id); + const promote = trpc.deploy.deployment.promote.useMutation({ onSuccess: () => { utils.invalidate(); @@ -56,12 +52,7 @@ export const PromotionDialog = ({ }); // hack to revalidate try { - // @ts-expect-error Their docs say it's here - collection.projects.utils.refetch(); - // @ts-expect-error Their docs say it's here - collection.deployments.utils.refetch(); - // @ts-expect-error Their docs say it's here - collection.domains.utils.refetch(); + refetchAll(); } catch (error) { console.error("Refetch error:", error); } @@ -115,7 +106,7 @@ export const PromotionDialog = ({ showSignal={true} />
- {domains.data.map((domain) => ( + {domains.map((domain) => (

Domain

diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/rollback-dialog.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/rollback-dialog.tsx index 91e55d06e4..cff83186d8 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/rollback-dialog.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/rollback-dialog.tsx @@ -1,13 +1,12 @@ "use client"; -import { type Deployment, collection } from "@/lib/collections"; +import type { Deployment } from "@/lib/collections"; import { shortenId } from "@/lib/shorten-id"; import { trpc } from "@/lib/trpc/client"; -import { inArray, useLiveQuery } from "@tanstack/react-db"; import { CircleInfo, CodeBranch, CodeCommit, Link4 } from "@unkey/icons"; import { Badge, Button, DialogContainer, toast } from "@unkey/ui"; import { StatusIndicator } from "../../../components/status-indicator"; -import { useProject } from "../../layout-provider"; +import { useProjectData } from "../../data-provider"; type DeploymentSectionProps = { title: string; @@ -40,15 +39,10 @@ export const RollbackDialog = ({ liveDeployment, }: RollbackDialogProps) => { const utils = trpc.useUtils(); + const { getEnvironmentOrLiveDomains, refetchAll } = useProjectData(); - const { - collections: { domains: domainCollection }, - } = useProject(); - const domains = useLiveQuery((q) => - q - .from({ domain: domainCollection }) - .where(({ domain }) => inArray(domain.sticky, ["environment", "live"])), - ); + // Filter for environment and live domains + const domains = getEnvironmentOrLiveDomains(); const rollback = trpc.deploy.deployment.rollback.useMutation({ onSuccess: () => { @@ -58,12 +52,7 @@ export const RollbackDialog = ({ }); // hack to revalidate try { - // @ts-expect-error Their docs say it's here - collection.projects.utils.refetch(); - // @ts-expect-error Their docs say it's here - collection.deployments.utils.refetch(); - // @ts-expect-error Their docs say it's here - collection.domains.utils.refetch(); + refetchAll(); } catch (error) { console.error("Refetch error:", error); } @@ -114,7 +103,7 @@ export const RollbackDialog = ({ showSignal={true} />
- {domains.data.map((domain) => ( + {domains.map((domain) => (

Domain

diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/actions/deployment-list-table-action.popover.constants.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/actions/deployment-list-table-action.popover.constants.tsx index 358ffc21b2..c9ff73e57a 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/actions/deployment-list-table-action.popover.constants.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/actions/deployment-list-table-action.popover.constants.tsx @@ -1,9 +1,8 @@ "use client"; -import { useProject } from "@/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/layout-provider"; +import { useProjectData } from "@/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/data-provider"; import { type MenuItem, TableActionPopover } from "@/components/logs/table-action.popover"; import { useWorkspaceNavigation } from "@/hooks/use-workspace-navigation"; import type { Deployment, Environment } from "@/lib/collections"; -import { eq, useLiveQuery } from "@tanstack/react-db"; import { ArrowDottedRotateAnticlockwise, ChevronUp, Layers3 } from "@unkey/icons"; import { useRouter } from "next/navigation"; import { useMemo } from "react"; @@ -22,13 +21,10 @@ export const DeploymentListTableActions = ({ environment, }: DeploymentListTableActionsProps) => { const workspace = useWorkspaceNavigation(); - const { collections } = useProject(); - const { data } = useLiveQuery((q) => - q - .from({ domain: collections.domains }) - .where(({ domain }) => eq(domain.deploymentId, selectedDeployment.id)) - .select(({ domain }) => ({ host: domain.fullyQualifiedDomainName })), - ); + const { getDomainsForDeployment } = useProjectData(); + const data = getDomainsForDeployment(selectedDeployment.id).map((domain) => ({ + host: domain.fullyQualifiedDomainName, + })); const router = useRouter(); // biome-ignore lint/correctness/useExhaustiveDependencies: its okay diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/actions/promotion-dialog.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/actions/promotion-dialog.tsx index 155c152226..9e9c18c1af 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/actions/promotion-dialog.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/actions/promotion-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { type Deployment, collection, collectionManager } from "@/lib/collections"; +import { type Deployment, collection } from "@/lib/collections"; import { shortenId } from "@/lib/shorten-id"; import { trpc } from "@/lib/trpc/client"; import { eq, inArray, useLiveQuery } from "@tanstack/react-db"; @@ -22,14 +22,14 @@ export const PromotionDialog = ({ liveDeployment, }: PromotionDialogProps) => { const utils = trpc.useUtils(); - const domainCollection = collectionManager.getProjectCollections( - liveDeployment.projectId, - ).domains; - const domains = useLiveQuery((q) => - q - .from({ domain: domainCollection }) - .where(({ domain }) => inArray(domain.sticky, ["environment", "live"])) - .where(({ domain }) => eq(domain.deploymentId, liveDeployment.id)), + const domains = useLiveQuery( + (q) => + q + .from({ domain: collection.domains }) + .where(({ domain }) => eq(domain.projectId, liveDeployment.projectId)) + .where(({ domain }) => inArray(domain.sticky, ["environment", "live"])) + .where(({ domain }) => eq(domain.deploymentId, liveDeployment.id)), + [liveDeployment.projectId, liveDeployment.id], ); const promote = trpc.deploy.deployment.promote.useMutation({ onSuccess: () => { @@ -39,11 +39,8 @@ export const PromotionDialog = ({ }); // hack to revalidate try { - // @ts-expect-error Their docs say it's here collection.projects.utils.refetch(); - // @ts-expect-error Their docs say it's here collection.deployments.utils.refetch(); - // @ts-expect-error Their docs say it's here collection.domains.utils.refetch(); } catch (error) { console.error("Refetch error:", error); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/actions/rollback-dialog.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/actions/rollback-dialog.tsx index afb05b5585..b1cc8f1acb 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/actions/rollback-dialog.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/actions/rollback-dialog.tsx @@ -2,9 +2,9 @@ import { type Deployment, collection } from "@/lib/collections"; import { trpc } from "@/lib/trpc/client"; -import { inArray, useLiveQuery } from "@tanstack/react-db"; +import { eq, inArray, useLiveQuery } from "@tanstack/react-db"; import { Button, DialogContainer, toast } from "@unkey/ui"; -import { useProject } from "../../../../../layout-provider"; +import { useProjectData } from "../../../../../data-provider"; import { DeploymentSection } from "./components/deployment-section"; import { DomainsSection } from "./components/domains-section"; @@ -23,13 +23,14 @@ export const RollbackDialog = ({ }: RollbackDialogProps) => { const utils = trpc.useUtils(); - const { - collections: { domains: domainCollection }, - } = useProject(); - const domains = useLiveQuery((q) => - q - .from({ domain: domainCollection }) - .where(({ domain }) => inArray(domain.sticky, ["environment", "live"])), + const { projectId } = useProjectData(); + const domains = useLiveQuery( + (q) => + q + .from({ domain: collection.domains }) + .where(({ domain }) => eq(domain.projectId, projectId)) + .where(({ domain }) => inArray(domain.sticky, ["environment", "live"])), + [projectId], ); const rollback = trpc.deploy.deployment.rollback.useMutation({ @@ -40,11 +41,8 @@ export const RollbackDialog = ({ }); // hack to revalidate try { - // @ts-expect-error Their docs say it's here collection.projects.utils.refetch(); - // @ts-expect-error Their docs say it's here collection.deployments.utils.refetch(); - // @ts-expect-error Their docs say it's here collection.domains.utils.refetch(); } catch (error) { console.error("Refetch error:", error); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/deployment-status-badge.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/deployment-status-badge.tsx index d7c7ada268..09701063e9 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/deployment-status-badge.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/deployment-status-badge.tsx @@ -93,31 +93,13 @@ export const DeploymentStatusBadge = ({ status, className }: DeploymentStatusBad )} > {animated && ( -
+
)} {label} - - {animated && ( - - )}
); }; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/domain_list.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/domain_list.tsx index 42315370c5..0540645c32 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/domain_list.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/domain_list.tsx @@ -1,33 +1,44 @@ -import { eq, useLiveQuery } from "@tanstack/react-db"; import { InfoTooltip } from "@unkey/ui"; -import { useProject } from "../../../../layout-provider"; +import { useProjectData } from "../../../../data-provider"; +import type { DeploymentStatus } from "../../../filters.schema"; import { DomainListSkeleton } from "./skeletons"; type Props = { deploymentId: string; + status: DeploymentStatus; }; -export const DomainList = ({ deploymentId }: Props) => { - const { collections } = useProject(); +export const DomainList = ({ deploymentId, status }: Props) => { + const { getDomainsForDeployment, isDomainsLoading } = useProjectData(); - const domains = useLiveQuery((q) => - q - .from({ domain: collections.domains }) - .where(({ domain }) => eq(domain.deploymentId, deploymentId)) - .orderBy(({ domain }) => domain.fullyQualifiedDomainName, "asc"), - ); + // Show placeholder for failed deployments + if (status === "failed") { + return ; + } - if (domains.isLoading || !domains.data.length) { + // Only show skeleton when actually loading + if (isDomainsLoading) { return ; } + // Get domains for this deployment and sort client-side + const domainsForDeployment = getDomainsForDeployment(deploymentId); + const sortedDomains = [...domainsForDeployment].sort((a, b) => + a.fullyQualifiedDomainName.localeCompare(b.fullyQualifiedDomainName), + ); + + // Handle empty domains (valid for non-failed deployments) + if (!sortedDomains.length) { + return ; + } + // Always show environment domain first, fallback to first domain if none - const environmentDomain = domains.data.find((d) => d.sticky === "environment"); - const primaryDomain = environmentDomain ?? domains.data[0]; - const additionalDomains = domains.data.filter((d) => d.id !== primaryDomain.id); + const environmentDomain = sortedDomains.find((d) => d.sticky === "environment"); + const primaryDomain = environmentDomain ?? sortedDomains[0]; + const additionalDomains = sortedDomains.filter((d) => d.id !== primaryDomain.id); // Single domain case - no tooltip needed - if (domains.data.length === 1) { + if (sortedDomains.length === 1) { return ( { deployment: Deployment; environment?: Environment; } | null>(null); - const { liveDeployment, deployments, project } = useDeployments(); + const { deployments } = useDeployments(); + const { project, getDeploymentById } = useProjectData(); + const liveDeploymentId = project?.liveDeploymentId; const selectedDeploymentId = selectedDeployment?.deployment.id; @@ -77,7 +80,7 @@ export const DeploymentsList = () => { width: "15%", headerClassName: "pl-[18px]", render: ({ deployment, environment }) => { - const isLive = liveDeployment?.id === deployment.id; + const isLive = liveDeploymentId === deployment.id; const iconContainer = ; return (
@@ -147,7 +150,7 @@ export const DeploymentsList = () => { render: ({ deployment }) => { return (
- +
); }, @@ -159,7 +162,9 @@ export const DeploymentsList = () => { headerClassName: "hidden 2xl:table-cell", cellClassName: "hidden 2xl:table-cell", render: ({ deployment }: { deployment: Deployment }) => { - return ( + return deployment.status === "failed" ? ( + + ) : (
@@ -177,7 +182,9 @@ export const DeploymentsList = () => { header: "Size", width: "15%", render: ({ deployment }: { deployment: Deployment }) => { - return ( + return deployment.status === "failed" ? ( + + ) : (
@@ -285,6 +292,7 @@ export const DeploymentsList = () => { deployment: Deployment; environment?: Environment; }) => { + const liveDeployment = getDeploymentById(deployment.id); return (
{ }, }, ]; - }, [selectedDeploymentId, liveDeployment, project]); + }, [selectedDeploymentId, project]); return ( { getRowClassName( deployment, selectedDeployment?.deployment.id ?? null, - liveDeployment?.id ?? null, + liveDeploymentId ?? null, project?.isRolledBack ?? false, ) } diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/utils/get-row-class.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/utils/get-row-class.ts index 05fc9b9a2e..0fc96e2709 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/utils/get-row-class.ts +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/utils/get-row-class.ts @@ -1,4 +1,4 @@ -import type { Deployment } from "@/lib/collections"; +import type { Deployment, Environment } from "@/lib/collections"; import { cn } from "@/lib/utils"; @@ -25,7 +25,7 @@ export const STATUS_STYLES = { }; export const FAILED_STATUS_STYLES = { - base: "text-grayA-9 bg-error-1", + base: "text-grayA-9 bg-error-2", hover: "hover:text-grayA-11 hover:bg-error-2", selected: "text-grayA-12 bg-error-3 hover:bg-error-3", badge: { @@ -47,7 +47,7 @@ export const ROLLED_BACK_STYLES = { }; export const getRowClassName = ( - deployment: Deployment, + { deployment }: { deployment: Deployment; environment: Environment }, selectedDeploymentId: string | null, liveDeploymentId: string | null, isRolledBack: boolean, diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/hooks/use-deployments.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/hooks/use-deployments.ts index 373aac510f..71b8ddc03a 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/hooks/use-deployments.ts +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/hooks/use-deployments.ts @@ -1,36 +1,21 @@ import { collection } from "@/lib/collections"; import { eq, gt, gte, lte, or, useLiveQuery } from "@tanstack/react-db"; import ms from "ms"; -import { useProject } from "../../layout-provider"; +import { useProjectData } from "../../data-provider"; import type { DeploymentListFilterField } from "../filters.schema"; import { useFilters } from "./use-filters"; export const useDeployments = () => { - const { projectId, collections } = useProject(); + const { projectId } = useProjectData(); const { filters } = useFilters(); - const project = useLiveQuery((q) => { - return q - .from({ project: collection.projects }) - .where(({ project }) => eq(project.id, projectId)) - .orderBy(({ project }) => project.id, "asc") - .limit(1); - }).data.at(0); - const liveDeploymentId = project?.liveDeploymentId; - const liveDeployment = useLiveQuery( - (q) => - q - .from({ deployment: collections.deployments }) - .where(({ deployment }) => eq(deployment.id, liveDeploymentId)) - .orderBy(({ deployment }) => deployment.createdAt, "desc") - .limit(1), - [liveDeploymentId], - ).data.at(0); const deployments = useLiveQuery( (q) => { // Query filtered environments // further down below we use this to rightJoin with deployments to filter deployments by environment - let environments = q.from({ environment: collections.environments }); + let environments = q + .from({ environment: collection.environments }) + .where(({ environment }) => eq(environment.projectId, projectId)); for (const filter of filters) { if (filter.field === "environment") { environments = environments.where(({ environment }) => @@ -40,7 +25,7 @@ export const useDeployments = () => { } let query = q - .from({ deployment: collections.deployments }) + .from({ deployment: collection.deployments }) .where(({ deployment }) => eq(deployment.projectId, projectId)); @@ -103,18 +88,16 @@ export const useDeployments = () => { return query .rightJoin({ environment: environments }, ({ environment, deployment }) => - eq(environment.id, deployment.environmentId), + eq(environment.id, deployment?.environmentId ?? ""), ) - .orderBy(({ deployment }) => deployment.createdAt, "desc") + .orderBy(({ deployment }) => deployment?.createdAt ?? 0, "desc") .limit(100); }, [projectId, filters], ); return { - project, deployments, - liveDeployment, }; }; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/hooks/use-deployment-logs.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/hooks/use-deployment-logs.tsx index e750c223e6..d6b81da93f 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/hooks/use-deployment-logs.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/active-deployment-card-logs/hooks/use-deployment-logs.tsx @@ -1,7 +1,6 @@ import { trpc } from "@/lib/trpc/client"; -import { eq, useLiveQuery } from "@tanstack/react-db"; import { useMemo, useRef, useState } from "react"; -import { useProject } from "../../../layout-provider"; +import { useProjectData } from "../../../data-provider"; const GATEWAY_LOGS_REFETCH_INTERVAL = 5000; const GATEWAY_LOGS_LIMIT = 50; @@ -50,16 +49,11 @@ export function useDeploymentLogs({ const [searchTerm, setSearchTerm] = useState(""); const [showFade, setShowFade] = useState(true); const scrollRef = useRef(null) as React.MutableRefObject; - const { collections } = useProject(); - - const deployment = useLiveQuery( - (q) => - q - .from({ deployment: collections.deployments }) - .where(({ deployment }) => eq(deployment.id, deploymentId)), - [deploymentId], - ); - const environmentId = deployment.data.at(0)?.environmentId ?? ""; + + const { getDeploymentById } = useProjectData(); + const deployment = getDeploymentById(deploymentId ?? ""); + const environmentId = deployment?.environmentId ?? ""; + const { data: sentinelData, isLoading: sentinelLoading } = trpc.deploy.sentinelLogs.query.useQuery( { diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/add-custom-domain.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/add-custom-domain.tsx index a5e2d5d3c2..6f3b8f32e1 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/add-custom-domain.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/add-custom-domain.tsx @@ -12,6 +12,7 @@ import { toast, } from "@unkey/ui"; import { useEffect, useRef, useState } from "react"; +import { useProjectData } from "../../data-provider"; import type { CustomDomain } from "./types"; // Basic domain validation regex @@ -29,7 +30,6 @@ function extractDomain(input: string): string { } type AddCustomDomainProps = { - projectId: string; environments: Array<{ id: string; slug: string }>; getExistingDomain: (domain: string) => CustomDomain | undefined; onCancel: () => void; @@ -37,12 +37,12 @@ type AddCustomDomainProps = { }; export function AddCustomDomain({ - projectId, environments, getExistingDomain, onCancel, onSuccess, }: AddCustomDomainProps) { + const { projectId } = useProjectData(); const addMutation = trpc.deploy.customDomain.add.useMutation(); const containerRef = useRef(null); const inputRef = useRef(null); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/custom-domain-row.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/custom-domain-row.tsx index c7e25db1ec..7eb518a1be 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/custom-domain-row.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/custom-domain-row.tsx @@ -22,11 +22,11 @@ import { toast, } from "@unkey/ui"; import { useEffect, useRef, useState } from "react"; +import { useProjectData } from "../../data-provider"; import type { CustomDomain, VerificationStatus } from "./types"; type CustomDomainRowProps = { domain: CustomDomain; - projectId: string; onDelete: () => void; onRetry: () => void; }; @@ -57,7 +57,8 @@ const statusConfig: Record< }, }; -export function CustomDomainRow({ domain, projectId, onDelete, onRetry }: CustomDomainRowProps) { +export function CustomDomainRow({ domain, onDelete, onRetry }: CustomDomainRowProps) { + const { projectId } = useProjectData(); const deleteMutation = trpc.deploy.customDomain.delete.useMutation(); const retryMutation = trpc.deploy.customDomain.retry.useMutation(); const [isConfirmOpen, setIsConfirmOpen] = useState(false); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/index.tsx index 5c653b005b..254fdbb9c7 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/index.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/index.tsx @@ -1,18 +1,20 @@ "use client"; -import { Link4, Plus } from "@unkey/icons"; +import { Plus } from "@unkey/icons"; import { Button } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; import { useState } from "react"; +import { EmptySection } from "../../components/empty-section"; +import { useProjectData } from "../../data-provider"; import { AddCustomDomain } from "./add-custom-domain"; import { CustomDomainRow, CustomDomainRowSkeleton } from "./custom-domain-row"; import { useCustomDomainsManager } from "./hooks/use-custom-domains-manager"; type CustomDomainsSectionProps = { - projectId: string; environments: Array<{ id: string; slug: string }>; }; -export function CustomDomainsSection({ projectId, environments }: CustomDomainsSectionProps) { +export function CustomDomainsSection({ environments }: CustomDomainsSectionProps) { + const { projectId } = useProjectData(); const { customDomains, isLoading, getExistingDomain, invalidate } = useCustomDomainsManager({ projectId, }); @@ -40,7 +42,6 @@ export function CustomDomainsSection({ projectId, environments }: CustomDomainsS @@ -49,7 +50,6 @@ export function CustomDomainsSection({ projectId, environments }: CustomDomainsS {isAddingNew && ( void; hasEnvironments: boolean }) { return ( -
-
- {/* Icon with subtle animation */} -
-
-
- -
-
- {/* Content */} -
-

No custom domains configured

- {hasEnvironments ? ( -

- Add a custom domain to serve your application from your own domain. -

- ) : ( -

- Create an environment first to add custom domains -

- )} -
- {/* Button */} - {hasEnvironments && ( - - )} -
-
+ + {hasEnvironments && ( + + )} + ); } diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/domain-row.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/domain-row.tsx index 4e185cacd3..193bb0420b 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/domain-row.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/domain-row.tsx @@ -2,7 +2,6 @@ import { CircleCheck, Link4, ShareUpRight } from "@unkey/icons"; import { Badge } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; import Link from "next/link"; -import { Card } from "../components/card"; type DomainRowProps = { domain: string; @@ -52,31 +51,3 @@ export const DomainRowSkeleton = () => {
); }; - -export const DomainRowEmpty = () => ( - -
- {/* Icon with subtle animation */} -
-
-
- -
-
- {/* Content */} -
-

No domains found

-

- Your configured domains will appear here once they're set up and verified. -

-
-
- -); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/env-variables-section/add-env-vars.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/env-variables-section/add-env-vars.tsx index d9581c1616..12dce95a4e 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/env-variables-section/add-env-vars.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/env-variables-section/add-env-vars.tsx @@ -284,14 +284,14 @@ export function AddEnvVars({ disabled={isSubmitting} /> + + {" "}
); })} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/env-variables-section/components/env-var-secret-switch.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/env-variables-section/components/env-var-secret-switch.tsx index 6f329ae69c..dfd38c1f07 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/env-variables-section/components/env-var-secret-switch.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/env-variables-section/components/env-var-secret-switch.tsx @@ -11,10 +11,19 @@ export const EnvVarSecretSwitch = ({ }) => { return (
- Secret + Secret setIsAddingNew(true); const cancelAdding = () => setIsAddingNew(false); - const showPlusButton = isExpanded && !isAddingNew; - return (
{/* Header */} -
+
{icon}
{title} {envVars.length > 0 && `(${envVars.length})`}
-
- - -
+ /> +
- - {/* Expandable Content */}
+ {/* Concave separator */} +
+
+
)} - {envVars.length === 0 && !isAddingNew && } + {envVars.length === 0 && !isAddingNew && ( + + } + > + + + )}
@@ -153,46 +165,3 @@ const getItemAnimationProps = (index: number, isExpanded: boolean) => { style: { transitionDelay: isExpanded ? `${delay}ms` : "0ms" }, }; }; - -// Concave separator component -function ConcaveSeparator({ isExpanded }: { isExpanded: boolean }) { - return ( -
-
-
-
-
- ); -} - -// Empty state component -function EmptyState() { - return ( -
-
- {/* Icon with subtle animation */} -
-
-
- -
-
- {/* Content */} -
-

No environment variables configured

-

- Add environment variables to configure your application's runtime settings. -

-
-
-
- ); -} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/project-details-expandables/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/project-details-expandables/index.tsx index 5bb81d2998..282a3b4b19 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/project-details-expandables/index.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/project-details-expandables/index.tsx @@ -7,14 +7,12 @@ type ProjectDetailsExpandableProps = { tableDistanceToTop: number; isOpen: boolean; onClose: () => void; - projectId: string; }; export const ProjectDetailsExpandable = ({ tableDistanceToTop, isOpen, onClose, - projectId, }: ProjectDetailsExpandableProps) => { return (
@@ -66,7 +64,7 @@ export const ProjectDetailsExpandable = ({ transitionDelay: isOpen ? "150ms" : "0ms", }} > - +
diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/project-details-expandables/project-details-content.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/project-details-expandables/project-details-content.tsx index 0f511e92fc..93f6f97818 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/project-details-expandables/project-details-content.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/project-details-expandables/project-details-content.tsx @@ -1,41 +1,27 @@ -import { collection } from "@/lib/collections"; -import { eq, useLiveQuery } from "@tanstack/react-db"; import { Cube } from "@unkey/icons"; import { Button, InfoTooltip } from "@unkey/ui"; -import { useProject } from "../../layout-provider"; +import { useProjectData } from "../../data-provider"; import { DetailSection } from "./detail-section"; import { createDetailSections } from "./sections"; -type ProjectDetailsContentProps = { - projectId: string; -}; +export const ProjectDetailsContent = () => { + const { getDomainsForDeployment, project, getDeploymentById } = useProjectData(); -export const ProjectDetailsContent = ({ projectId }: ProjectDetailsContentProps) => { - const { collections } = useProject(); - const query = useLiveQuery((q) => - q - .from({ project: collection.projects }) - .where(({ project }) => eq(project.id, projectId)) - .join({ deployment: collections.deployments }, ({ deployment, project }) => - eq(deployment.id, project.liveDeploymentId), - ) - .orderBy(({ project }) => project.id, "asc") - .limit(1), - ); + const deployment = project?.liveDeploymentId + ? getDeploymentById(project.liveDeploymentId) + : undefined; - const data = query.data.at(0); - const { data: domainsData } = useLiveQuery( - (q) => - q - .from({ domain: collections.domains }) - .where(({ domain }) => eq(domain.deploymentId, data?.project.liveDeploymentId)) - .select(({ domain }) => ({ - domain: domain.fullyQualifiedDomainName, - environment: domain.sticky, + const data = project && deployment ? { project, deployment } : undefined; + + // Get domains from provider and transform + const domainsData = data?.project.liveDeploymentId + ? getDomainsForDeployment(data.project.liveDeploymentId) + .map((d) => ({ + domain: d.fullyQualifiedDomainName, + environment: d.sticky, })) - .orderBy(({ domain }) => domain.id, "asc"), - [data?.project.liveDeploymentId], - ); + .sort((a, b) => a.domain.localeCompare(b.domain)) + : []; if (!data?.deployment) { return null; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/project-details-expandables/sections.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/project-details-expandables/sections.tsx index 041391160e..e57ba7c242 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/project-details-expandables/sections.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/project-details-expandables/sections.tsx @@ -134,7 +134,7 @@ export const createDetailSections = ( alignment: "start", content: (
- +
), }, diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/project-details-expandables/sections/open-api-diff.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/project-details-expandables/sections/open-api-diff.tsx index 06d7a9e102..84b6939fb9 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/project-details-expandables/sections/open-api-diff.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/project-details-expandables/sections/open-api-diff.tsx @@ -1,13 +1,14 @@ "use client"; import type { GetOpenApiDiffResponse } from "@/gen/proto/ctrl/v1/openapi_pb"; +import { collection } from "@/lib/collections"; import { shortenId } from "@/lib/shorten-id"; import { trpc } from "@/lib/trpc/client"; -import { useLiveQuery } from "@tanstack/react-db"; +import { eq, useLiveQuery } from "@tanstack/react-db"; import { ArrowRight } from "@unkey/icons"; import Link from "next/link"; import { useParams } from "next/navigation"; import { type DiffStatus, StatusIndicator } from "../../../../components/status-indicator"; -import { useProject } from "../../../layout-provider"; +import { useProjectData } from "../../../data-provider"; const getDiffStatus = (data?: GetOpenApiDiffResponse): DiffStatus => { if (!data) { @@ -27,18 +28,20 @@ const getDiffStatus = (data?: GetOpenApiDiffResponse): DiffStatus => { export const OpenApiDiff = () => { const params = useParams(); - const { collections, liveDeploymentId } = useProject(); + const { projectId, project } = useProjectData(); + const liveDeploymentId = project?.liveDeploymentId; const query = useLiveQuery( (q) => q - .from({ deployment: collections.deployments }) + .from({ deployment: collection.deployments }) + .where(({ deployment }) => eq(deployment.projectId, projectId)) .orderBy(({ deployment }) => deployment.createdAt, "desc") .limit(2) .select((c) => ({ id: c.deployment.id, })), - [liveDeploymentId], + [projectId, liveDeploymentId], ); const newDeployment = query.data?.find((d) => d.id !== liveDeploymentId); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/layout-provider.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/layout-provider.tsx index f701ef9b37..59667ebee5 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/layout-provider.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/layout-provider.tsx @@ -1,22 +1,16 @@ -import type { collectionManager } from "@/lib/collections"; import { createContext, useContext } from "react"; type ProjectLayoutContextType = { isDetailsOpen: boolean; setIsDetailsOpen: (open: boolean) => void; - - projectId: string; - liveDeploymentId?: string | null; - - collections: ReturnType; }; export const ProjectLayoutContext = createContext(null); -export const useProject = () => { +export const useProjectLayout = () => { const context = useContext(ProjectLayoutContext); if (!context) { - throw new Error("useProject must be used within ProjectLayout"); + throw new Error("useProjectLayout must be used within ProjectLayout"); } return context; }; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/navigations/project-navigation.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/navigations/project-navigation.tsx index 24c3ed0287..3cf012997f 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/navigations/project-navigation.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/navigations/project-navigation.tsx @@ -4,7 +4,7 @@ import { NavbarActionButton } from "@/components/navigation/action-button"; import { Navbar } from "@/components/navigation/navbar"; import { useWorkspaceNavigation } from "@/hooks/use-workspace-navigation"; import { collection } from "@/lib/collections"; -import { eq, useLiveQuery } from "@tanstack/react-db"; +import { useLiveQuery } from "@tanstack/react-db"; import { ArrowDottedRotateAnticlockwise, ChevronExpandY, @@ -18,11 +18,11 @@ import { Button, InfoTooltip, Separator } from "@unkey/ui"; import { useRef } from "react"; import { RepoDisplay } from "../../../_components/list/repo-display"; import { DisabledWrapper } from "../../components/disabled-wrapper"; +import { useProjectData } from "../data-provider"; import { useBreadcrumbConfig } from "./use-breadcrumb-config"; const BORDER_OFFSET = 1; type ProjectNavigationProps = { - projectId: string; onMount: (distanceToTop: number) => void; onClick: () => void; isDetailsOpen: boolean; @@ -30,7 +30,6 @@ type ProjectNavigationProps = { }; export const ProjectNavigation = ({ - projectId, onMount, isDetailsOpen, liveDeploymentId, @@ -44,16 +43,10 @@ export const ProjectNavigation = ({ })), ); - const activeProject = useLiveQuery((q) => - q - .from({ project: collection.projects }) - .where(({ project }) => eq(project.id, projectId)) - .select(({ project }) => ({ - id: project.id, - name: project.name, - repositoryFullName: project.repositoryFullName, - })), - ).data.at(0); + const { projectId, project } = useProjectData(); + const activeProject = project + ? { id: project.id, name: project.name, repositoryFullName: project.repositoryFullName } + : undefined; const basePath = `/${workspace.slug}/projects`; const breadcrumbs = useBreadcrumbConfig({ diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/openapi-diff/components/deployment-select.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/openapi-diff/components/deployment-select.tsx index 23b377c401..b3c9721724 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/openapi-diff/components/deployment-select.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/openapi-diff/components/deployment-select.tsx @@ -11,7 +11,7 @@ import { } from "@unkey/ui"; import { format } from "date-fns"; import { PulseIndicator } from "../../../components/status-indicator"; -import { useProject } from "../../layout-provider"; +import { useProjectData } from "../../data-provider"; type DeploymentSelectProps = { value: string; @@ -36,7 +36,8 @@ export function DeploymentSelect({ id, disabledDeploymentId, }: DeploymentSelectProps) { - const { liveDeploymentId } = useProject(); + const { project } = useProjectData(); + const liveDeploymentId = project?.liveDeploymentId; const latestDeploymentId = deployments.find( ({ deployment }) => deployment.id !== liveDeploymentId, )?.deployment.id; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/openapi-diff/page.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/openapi-diff/page.tsx index 5ad4c4b8e7..7efa21cd48 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/openapi-diff/page.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/openapi-diff/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { collection } from "@/lib/collections"; import { shortenId } from "@/lib/shorten-id"; import { trpc } from "@/lib/trpc/client"; import { eq, useLiveQuery } from "@tanstack/react-db"; @@ -9,25 +10,29 @@ import { useSearchParams } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; import { ProjectContentWrapper } from "../../components/project-content-wrapper"; import { Card } from "../components/card"; -import { useProject } from "../layout-provider"; +import { useProjectData } from "../data-provider"; import { DiffViewerContent } from "./components/client"; import { DeploymentSelect } from "./components/deployment-select"; export default function DiffPage() { - const { collections, liveDeploymentId } = useProject(); + const { projectId, project } = useProjectData(); + const liveDeploymentId = project?.liveDeploymentId; const searchParams = useSearchParams(); const [selectedFromDeployment, setSelectedFromDeployment] = useState(""); const [selectedToDeployment, setSelectedToDeployment] = useState(""); - const deployments = useLiveQuery((q) => - q - .from({ deployment: collections.deployments }) - .join({ environment: collections.environments }, ({ environment, deployment }) => - eq(environment.id, deployment.environmentId), - ) - .orderBy(({ deployment }) => deployment.createdAt, "desc") - .limit(100), + const deployments = useLiveQuery( + (q) => + q + .from({ deployment: collection.deployments }) + .where(({ deployment }) => eq(deployment.projectId, projectId)) + .join({ environment: collection.environments }, ({ environment, deployment }) => + eq(environment.id, deployment.environmentId), + ) + .orderBy(({ deployment }) => deployment.createdAt, "desc") + .limit(100), + [projectId], ); const sortedDeployments = deployments.data ?? []; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/requests/components/controls/components/sentinel-logs-filters/components/sentinel-logs-environment-filter.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/requests/components/controls/components/sentinel-logs-filters/components/sentinel-logs-environment-filter.tsx index b3b0dd52d2..62d41e618c 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/requests/components/controls/components/sentinel-logs-filters/components/sentinel-logs-environment-filter.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/requests/components/controls/components/sentinel-logs-filters/components/sentinel-logs-environment-filter.tsx @@ -1,17 +1,14 @@ "use client"; -import { useProject } from "@/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/layout-provider"; import { FilterCheckbox } from "@/components/logs/checkbox/filter-checkbox"; -import { useLiveQuery } from "@tanstack/react-db"; +import { useProjectData } from "../../../../../../data-provider"; import { useSentinelLogsFilters } from "../../../../../hooks/use-sentinel-logs-filters"; export const SentinelEnvironmentFilter = () => { const { filters, updateFilters } = useSentinelLogsFilters(); - const { collections } = useProject(); + const { environments } = useProjectData(); - const environments = useLiveQuery((q) => q.from({ environment: collections.environments })); - - const options = environments.data.map((environment, i) => ({ + const options = environments.map((environment, i) => ({ id: i, slug: environment.slug, environmentId: environment.id, diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/requests/components/table/hooks/use-sentinel-logs-query.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/requests/components/table/hooks/use-sentinel-logs-query.ts index 4fe860f380..fe9b557dea 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/requests/components/table/hooks/use-sentinel-logs-query.ts +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/requests/components/table/hooks/use-sentinel-logs-query.ts @@ -4,7 +4,7 @@ import { trpc } from "@/lib/trpc/client"; import { useQueryTime } from "@/providers/query-time-provider"; import type { SentinelLogsResponse } from "@unkey/clickhouse/src/sentinel"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { useProject } from "../../../../layout-provider"; +import { useProjectData } from "../../../../data-provider"; import { useSentinelLogsFilters } from "../../../hooks/use-sentinel-logs-filters"; type UseSentinelLogsQueryParams = { @@ -20,7 +20,7 @@ export function useSentinelLogsQuery({ startPolling = false, pollIntervalMs = 2000, }: UseSentinelLogsQueryParams = {}) { - const { projectId } = useProject(); + const { projectId } = useProjectData(); const { filters } = useSentinelLogsFilters(); const queryClient = trpc.useUtils(); const { queryTime: timestamp } = useQueryTime(); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/requests/components/table/sentinel-log-details/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/requests/components/table/sentinel-log-details/index.tsx index 06175ed5cf..afff47e8e9 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/requests/components/table/sentinel-log-details/index.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/requests/components/table/sentinel-log-details/index.tsx @@ -2,6 +2,7 @@ import { safeParseJson } from "@/app/(app)/[workspaceSlug]/logs/utils"; import { EMPTY_TEXT, LogDetails } from "@/components/logs/details/log-details"; import { LogSection } from "@/components/logs/details/log-details/components/log-section"; +import { collection } from "@/lib/collections"; import { shortenId } from "@/lib/shorten-id"; import { cn } from "@/lib/utils"; import { eq, useLiveQuery } from "@tanstack/react-db"; @@ -9,7 +10,7 @@ import type { SentinelLogsResponse } from "@unkey/clickhouse/src/sentinel"; import { CodeBranch, CodeCommit, User } from "@unkey/icons"; import { Badge, CopyButton } from "@unkey/ui"; import type React from "react"; -import { useProject } from "../../../../layout-provider"; +import { useProjectData } from "../../../../data-provider"; import { useSentinelLogsContext } from "../../../context/sentinel-logs-provider"; type Props = { @@ -18,7 +19,7 @@ type Props = { export const SentinelLogDetails = ({ distanceToTop }: Props) => { const { setSelectedLog, selectedLog: log } = useSentinelLogsContext(); - const { collections } = useProject(); + const { projectId } = useProjectData(); const handleClose = () => { setSelectedLog(null); @@ -27,13 +28,14 @@ export const SentinelLogDetails = ({ distanceToTop }: Props) => { const { data } = useLiveQuery( (q) => { return q - .from({ deployment: collections.deployments }) - .join({ environment: collections.environments }, ({ deployment, environment }) => + .from({ deployment: collection.deployments }) + .where(({ deployment }) => eq(deployment.projectId, projectId)) + .join({ environment: collection.environments }, ({ deployment, environment }) => eq(deployment.environmentId, environment.id), ) .where(({ deployment }) => eq(deployment.id, log?.deployment_id)); }, - [log?.deployment_id], + [projectId, log?.deployment_id], ); const deployment = data.at(0)?.deployment; const environment = data.at(0)?.environment; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/requests/context/sentinel-logs-provider.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/requests/context/sentinel-logs-provider.tsx index cc61397b45..aa53272555 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/requests/context/sentinel-logs-provider.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/requests/context/sentinel-logs-provider.tsx @@ -1,7 +1,7 @@ "use client"; import type { SentinelLogsResponse } from "@unkey/clickhouse/src/sentinel"; import { type PropsWithChildren, createContext, useContext, useState } from "react"; -import { useProject } from "../../layout-provider"; +import { useProjectLayout } from "../../layout-provider"; type SentinelLogsContextType = { selectedLog: SentinelLogsResponse | null; @@ -13,7 +13,7 @@ type SentinelLogsContextType = { const SentinelLogsContext = createContext(null); export const SentinelLogsProvider = ({ children }: PropsWithChildren) => { - const { setIsDetailsOpen } = useProject(); + const { setIsDetailsOpen } = useProjectLayout(); const [selectedLog, setSelectedLog] = useState(null); const [isLive, setIsLive] = useState(false); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/github-app-card.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/github-app-card.tsx index 9933170837..2cfc1f361f 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/github-app-card.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/github-app-card.tsx @@ -1,12 +1,15 @@ +"use client"; + import { Github } from "@unkey/icons"; import { SettingCard, buttonVariants } from "@unkey/ui"; +import { useProjectData } from "../../data-provider"; type Props = { - projectId: string; hasInstallations: boolean; }; -export const GitHubAppCard: React.FC = ({ projectId, hasInstallations }) => { +export const GitHubAppCard: React.FC = ({ hasInstallations }) => { + const { projectId } = useProjectData(); const state = JSON.stringify({ projectId }); const installUrl = `https://github.com/apps/${process.env.NEXT_PUBLIC_GITHUB_APP_NAME}/installations/new?state=${encodeURIComponent(state)}`; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/github-settings-client.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/github-settings-client.tsx index c7ca7021d7..7805fc87f4 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/github-settings-client.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/github-settings-client.tsx @@ -2,14 +2,12 @@ import { trpc } from "@/lib/trpc/client"; import { Loading, toast } from "@unkey/ui"; +import { useProjectData } from "../../data-provider"; import { GitHubAppCard } from "./github-app-card"; import { RepositoryCard } from "./repository-card"; -type Props = { - projectId: string; -}; - -export const GitHubSettingsClient: React.FC = ({ projectId }) => { +export const GitHubSettingsClient: React.FC = () => { + const { projectId } = useProjectData(); const utils = trpc.useUtils(); const { data, isLoading, refetch } = trpc.github.getInstallations.useQuery( @@ -46,16 +44,15 @@ export const GitHubSettingsClient: React.FC = ({ projectId }) => {
{hasInstallations ? ( <> - + disconnectRepoMutation.mutate({ projectId })} isDisconnecting={disconnectRepoMutation.isLoading} /> ) : ( - + )}
); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/repository-card.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/repository-card.tsx index f25dc07ba7..3f81570e0d 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/repository-card.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/repository-card.tsx @@ -5,20 +5,20 @@ import { Combobox } from "@/components/ui/combobox"; import { trpc } from "@/lib/trpc/client"; import { Button, SettingCard, toast } from "@unkey/ui"; import { useMemo, useState } from "react"; +import { useProjectData } from "../../data-provider"; type Props = { - projectId: string; connectedRepo: string | null; onDisconnect: () => void; isDisconnecting: boolean; }; export const RepositoryCard: React.FC = ({ - projectId, connectedRepo, onDisconnect, isDisconnecting, }) => { + const { projectId } = useProjectData(); const utils = trpc.useUtils(); const [selectedRepo, setSelectedRepo] = useState(""); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/page.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/page.tsx index ea3cdbeef8..7797ac8835 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/page.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/page.tsx @@ -1,34 +1,22 @@ "use client"; - -import { trpc } from "@/lib/trpc/client"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@unkey/ui"; import { parseAsString, useQueryState } from "nuqs"; -import { useEffect } from "react"; -import { useProject } from "../layout-provider"; +import { useProjectData } from "../data-provider"; import { BuildSettings } from "./components/build-settings"; import { GitHubSettingsClient } from "./components/github-settings-client"; import { RuntimeApplicationSettings } from "./components/runtime-application-settings"; import { RuntimeScalingSettings } from "./components/runtime-scaling-settings"; export default function SettingsPage() { - const { projectId } = useProject(); - const { data: environments } = trpc.deploy.environment.list.useQuery({ - projectId, - }); + const { environments } = useProjectData(); const [environmentId, setEnvironmentId] = useQueryState( "environmentId", - parseAsString.withOptions({ + parseAsString.withDefault(environments.length > 0 ? environments[0].id : "").withOptions({ history: "replace", shallow: true, }), ); - useEffect(() => { - if (environments && environments.length > 0 && environmentId === null) { - setEnvironmentId(environments[0].id); - } - }, [environments, environmentId, setEnvironmentId]); - return (
@@ -38,7 +26,7 @@ export default function SettingsPage() {

Source

- +
diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/active-deployment-card/components/active-deployment-card-empty.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/active-deployment-card/components/active-deployment-card-empty.tsx index 290a8d990f..4eeb1e7473 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/active-deployment-card/components/active-deployment-card-empty.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/active-deployment-card/components/active-deployment-card-empty.tsx @@ -1,6 +1,7 @@ import { Cloud, Plus } from "@unkey/icons"; -import { Button, Card } from "@unkey/ui"; +import { Button } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; +import { EmptySection } from "../../../(overview)/components/empty-section"; type Props = { onCreateDeployment?: () => void; @@ -8,41 +9,17 @@ type Props = { }; export const ActiveDeploymentCardEmpty = ({ onCreateDeployment, className }: Props) => ( - } + className={cn("min-h-[200px]", className)} > -
- {/* Icon with subtle animation */} -
-
-
- -
-
- - {/* Content */} -
-

No active deployments

-

- Your deployments will appear here once you push code to your connected repository or - trigger a manual deployment. -

-
- - {/* Action button */} - {onCreateDeployment && ( - - )} -
- + {onCreateDeployment && ( + + )} + ); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/active-deployment-card/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/active-deployment-card/index.tsx index c30a360e5b..febb5c6d50 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/active-deployment-card/index.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/active-deployment-card/index.tsx @@ -1,10 +1,9 @@ "use client"; -import { eq, useLiveQuery } from "@tanstack/react-db"; import { CodeBranch, CodeCommit } from "@unkey/icons"; import { TimestampInfo } from "@unkey/ui"; import { Card } from "../../(overview)/components/card"; -import { useProject } from "../../(overview)/layout-provider"; +import { useProjectData } from "../../(overview)/data-provider"; import { Avatar } from "../../components/git-avatar"; import { InfoChip } from "../../components/info-chip"; import { StatusIndicator } from "../../components/status-indicator"; @@ -24,17 +23,10 @@ export const ActiveDeploymentCard = ({ trailingContent, expandableContent, }: Props) => { - const { collections } = useProject(); - const { data, isLoading } = useLiveQuery( - (q) => - q - .from({ deployment: collections.deployments }) - .where(({ deployment }) => eq(deployment.id, deploymentId)), - [deploymentId], - ); - const deployment = data.at(0); + const { getDeploymentById, isDeploymentsLoading } = useProjectData(); + const deployment = deploymentId ? getDeploymentById(deploymentId) : undefined; - if (isLoading) { + if (isDeploymentsLoading) { return ; } if (!deployment) { @@ -48,7 +40,9 @@ export const ActiveDeploymentCard = ({
{deployment.id}
-
{deployment.gitCommitMessage}
+ {deployment.gitCommitMessage && ( +
{deployment.gitCommitMessage}
+ )}
diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/project-content-wrapper.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/project-content-wrapper.tsx index c9d66cb478..2210103fa6 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/project-content-wrapper.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/project-content-wrapper.tsx @@ -2,7 +2,7 @@ import { cn } from "@unkey/ui/src/lib/utils"; import type { PropsWithChildren } from "react"; -import { useProject } from "../(overview)/layout-provider"; +import { useProjectLayout } from "../(overview)/layout-provider"; type ProjectContentWrapperProps = PropsWithChildren<{ className?: React.ComponentProps<"div">["className"]; @@ -24,7 +24,7 @@ export function ProjectContentWrapper({ centered = false, maxWidth = "960px", }: ProjectContentWrapperProps) { - const { isDetailsOpen } = useProject(); + const { isDetailsOpen } = useProjectLayout(); return (
+ {Array.from({ length: 3 }).map((_, idx) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton items don't need stable keys +
+ ))} +
+ ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/region-flags.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/region-flags.tsx index 6122b31ff9..7af5bd7238 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/region-flags.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/region-flags.tsx @@ -1,11 +1,16 @@ import type { FlagCode } from "@/lib/trpc/routers/deploy/network/utils"; import { RegionFlag } from "./region-flag"; +import { RegionFlagsSkeleton } from "./region-flags-skeleton"; type RegionFlagsProps = { instances: { id: string; flagCode: FlagCode }[]; }; export function RegionFlags({ instances }: RegionFlagsProps) { + if (instances.length === 0) { + return ; + } + return (
{instances.map((instance) => ( diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/layout.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/layout.tsx index 094d8e2145..8f00f23384 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/layout.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/layout.tsx @@ -1,31 +1,24 @@ "use client"; -import { collection, collectionManager } from "@/lib/collections"; -import { eq, useLiveQuery } from "@tanstack/react-db"; import { usePathname } from "next/navigation"; -import { use, useEffect, useState } from "react"; +import { type PropsWithChildren, useState } from "react"; +import { ProjectDataProvider, useProjectData } from "./(overview)/data-provider"; import { ProjectDetailsExpandable } from "./(overview)/details/project-details-expandables"; import { ProjectLayoutContext } from "./(overview)/layout-provider"; import { ProjectNavigation } from "./(overview)/navigations/project-navigation"; -export default function ProjectLayoutWrapper(props: { - children: React.ReactNode; - params: Promise<{ projectId: string }>; -}) { - const params = use(props.params); - - const { projectId } = params; - - const { children } = props; - - return {children}; +export default function ProjectLayoutWrapper({ children }: PropsWithChildren) { + return {children}; } -type ProjectLayoutProps = { - projectId: string; - children: React.ReactNode; +const ProjectLayout = ({ children }: PropsWithChildren) => { + return ( + + {children} + + ); }; -const ProjectLayout = ({ projectId, children }: ProjectLayoutProps) => { +const ProjectLayoutInner = ({ children }: PropsWithChildren) => { const [tableDistanceToTop, setTableDistanceToTop] = useState(0); const [isDetailsOpen, setIsDetailsOpen] = useState(false); @@ -33,37 +26,19 @@ const ProjectLayout = ({ projectId, children }: ProjectLayoutProps) => { const isOnDeploymentDetail = pathname?.includes("/deployments/") && pathname.split("/").filter(Boolean).length >= 5; // /workspace/projects/projectId/deployments/deploymentId/* - const collections = collectionManager.getProjectCollections(projectId); - - const projects = useLiveQuery((q) => - q.from({ project: collection.projects }).where(({ project }) => eq(project.id, projectId)), - ); - - const liveDeploymentId = projects.data.at(0)?.liveDeploymentId; - - // Refetch domains when live deployment changes to show domains for the currently active deployment. - // biome-ignore lint/correctness/useExhaustiveDependencies: Read above. - useEffect(() => { - //@ts-expect-error Without this we can't refetch domains on-demand. It's either this or we do `refetchInternal` on domains collection level. - // Second approach causing too any re-renders. This is fine because data is partitioned and centralized in this context. - // Until they introduce a way to invalidate collections properly we stick to this. - collections.domains.utils.refetch(); - }, [liveDeploymentId]); + const { project } = useProjectData(); + const liveDeploymentId = project?.liveDeploymentId; return (
{!isOnDeploymentDetail && ( setIsDetailsOpen(!isDetailsOpen)} isDetailsOpen={isDetailsOpen} liveDeploymentId={liveDeploymentId} @@ -73,7 +48,6 @@ const ProjectLayout = ({ projectId, children }: ProjectLayoutProps) => {
{children}
setIsDetailsOpen(false)} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/page.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/page.tsx index facb3b9fa6..7032a003d6 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/page.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/page.tsx @@ -1,45 +1,37 @@ "use client"; -import { collection } from "@/lib/collections"; -import { eq, useLiveQuery } from "@tanstack/react-db"; import { Cloud, Earth, FolderCloud, Link4, Page2 } from "@unkey/icons"; +import { EmptySection } from "./(overview)/components/empty-section"; +import { useProjectData } from "./(overview)/data-provider"; import { DeploymentLogsContent } from "./(overview)/details/active-deployment-card-logs/components/deployment-logs-content"; import { DeploymentLogsTrigger } from "./(overview)/details/active-deployment-card-logs/components/deployment-logs-trigger"; import { DeploymentLogsProvider } from "./(overview)/details/active-deployment-card-logs/providers/deployment-logs-provider"; import { CustomDomainsSection } from "./(overview)/details/custom-domains-section"; -import { DomainRow, DomainRowEmpty, DomainRowSkeleton } from "./(overview)/details/domain-row"; +import { DomainRow, DomainRowSkeleton } from "./(overview)/details/domain-row"; import { EnvironmentVariablesSection } from "./(overview)/details/env-variables-section"; -import { useProject } from "./(overview)/layout-provider"; import { ActiveDeploymentCard } from "./components/active-deployment-card"; import { DeploymentStatusBadge } from "./components/deployment-status-badge"; import { ProjectContentWrapper } from "./components/project-content-wrapper"; import { Section, SectionHeader } from "./components/section"; export default function ProjectDetails() { - const { projectId, collections, liveDeploymentId } = useProject(); + const { + projectId, + getDomainsForDeployment, + isDomainsLoading, + getDeploymentById, + project, + environments, + } = useProjectData(); - const projects = useLiveQuery((q) => - q.from({ project: collection.projects }).where(({ project }) => eq(project.id, projectId)), - ); + const liveDeploymentId = project?.liveDeploymentId; - const project = projects.data.at(0); - const { data: domains, isLoading: isDomainsLoading } = useLiveQuery( - (q) => - q - .from({ domain: collections.domains }) - .where(({ domain }) => eq(domain.deploymentId, liveDeploymentId)), - [liveDeploymentId], - ); + // Get domains for live deployment + const domains = liveDeploymentId ? getDomainsForDeployment(liveDeploymentId) : []; - const { data: environments } = useLiveQuery((q) => q.from({ env: collections.environments })); - - const deployment = useLiveQuery( - (q) => - q - .from({ deployment: collections.deployments }) - .where(({ deployment }) => eq(deployment.id, project?.liveDeploymentId)), - [project?.liveDeploymentId], - ); - const deploymentStatus = deployment.data.at(0)?.status; + // Get deployment from provider + const deploymentStatus = liveDeploymentId + ? getDeploymentById(liveDeploymentId)?.status + : undefined; return ( @@ -75,12 +67,15 @@ export default function ProjectDetails() { - ) : domains?.length > 0 ? ( + ) : domains.length > 0 ? ( domains.map((domain) => ( )) ) : ( - + )}
@@ -90,8 +85,7 @@ export default function ProjectDetails() { title="Custom Domains" /> ({ id: env.id, slug: env.slug })) ?? []} + environments={environments.map((env) => ({ id: env.id, slug: env.slug }))} />
@@ -100,16 +94,15 @@ export default function ProjectDetails() { title="Environment Variables" />
- {environments?.map((env) => ( + {environments.map((env) => ( } title={env.slug} - projectId={projectId} environment={env.slug} /> ))} - {environments?.length === 0 && ( + {environments.length === 0 && (
No environments configured
diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/_components/list/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/_components/list/index.tsx index cc512dddca..45dcc26f19 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/_components/list/index.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/_components/list/index.tsx @@ -1,5 +1,5 @@ import { ProximityPrefetch } from "@/components/proximity-prefetch"; -import { collection, collectionManager } from "@/lib/collections"; +import { collection } from "@/lib/collections"; import { ilike, useLiveQuery } from "@tanstack/react-db"; import { BookBookmark, Dots } from "@unkey/icons"; import { Button, Empty } from "@unkey/ui"; @@ -77,14 +77,7 @@ export const ProjectsList = () => { }} > {projects.data.map((project) => ( - { - collectionManager.preloadProject(project.id); - }} - > + + />{" "} )); Switch.displayName = SwitchPrimitives.Root.displayName; diff --git a/web/apps/dashboard/lib/collections/deploy/deployments.ts b/web/apps/dashboard/lib/collections/deploy/deployments.ts index c6ba3c7bad..576333897d 100644 --- a/web/apps/dashboard/lib/collections/deploy/deployments.ts +++ b/web/apps/dashboard/lib/collections/deploy/deployments.ts @@ -4,6 +4,7 @@ import { queryCollectionOptions } from "@tanstack/query-db-collection"; import { createCollection } from "@tanstack/react-db"; import { z } from "zod"; import { queryClient, trpcClient } from "../client"; +import { parseProjectIdFromWhere, validateProjectIdInQuery } from "./utils"; const schema = z.object({ id: z.string(), @@ -33,20 +34,35 @@ const schema = z.object({ export type Deployment = z.infer; -export function createDeploymentsCollection(projectId: string) { - if (!projectId) { - throw new Error("projectId is required to create deployments collection"); - } +/** + * Global deployments collection. + * + * IMPORTANT: All queries MUST filter by projectId: + * .where(({ deployment }) => eq(deployment.projectId, projectId)) + */ +export const deployments = createCollection( + queryCollectionOptions({ + queryClient, + queryKey: (opts) => { + const projectId = parseProjectIdFromWhere(opts.where); + return projectId ? ["deployments", projectId] : ["deployments"]; + }, + retry: 3, + syncMode: "on-demand", + refetchInterval: 5000, + queryFn: async (ctx) => { + const options = ctx.meta?.loadSubsetOptions; - return createCollection( - queryCollectionOptions({ - queryClient, - queryKey: [projectId, "deployments"], - retry: 3, - refetchInterval: 5000, - queryFn: () => trpcClient.deploy.deployment.list.query({ projectId }), - getKey: (item) => item.id, - id: `${projectId}-deployments`, - }), - ); -} + validateProjectIdInQuery(options?.where); + const projectId = parseProjectIdFromWhere(options?.where); + + if (!projectId) { + throw new Error("Query must include eq(collection.projectId, projectId) constraint"); + } + + return trpcClient.deploy.deployment.list.query({ projectId }); + }, + getKey: (item) => item.id, + id: "deployments", + }), +); diff --git a/web/apps/dashboard/lib/collections/deploy/domains.ts b/web/apps/dashboard/lib/collections/deploy/domains.ts index 8ce3a6cfb4..69ab1cbd47 100644 --- a/web/apps/dashboard/lib/collections/deploy/domains.ts +++ b/web/apps/dashboard/lib/collections/deploy/domains.ts @@ -3,6 +3,7 @@ import { queryCollectionOptions } from "@tanstack/query-db-collection"; import { createCollection } from "@tanstack/react-db"; import { z } from "zod"; import { queryClient, trpcClient } from "../client"; +import { parseProjectIdFromWhere, validateProjectIdInQuery } from "./utils"; const schema = z.object({ id: z.string(), @@ -17,19 +18,34 @@ const schema = z.object({ export type Domain = z.infer; -export function createDomainsCollection(projectId: string) { - if (!projectId) { - throw new Error("projectId is required to create domains collection"); - } +/** + * Global domains collection. + * + * IMPORTANT: All queries MUST filter by projectId: + * .where(({ domain }) => eq(domain.projectId, projectId)) + */ +export const domains = createCollection( + queryCollectionOptions({ + queryClient, + syncMode: "on-demand", + queryKey: (opts) => { + const projectId = parseProjectIdFromWhere(opts.where); + return projectId ? ["domains", projectId] : ["domains"]; + }, + retry: 3, + queryFn: async (ctx) => { + const options = ctx.meta?.loadSubsetOptions; - return createCollection( - queryCollectionOptions({ - queryClient, - queryKey: [projectId, "domains"], - retry: 3, - queryFn: () => trpcClient.deploy.domain.list.query({ projectId }), - getKey: (item) => item.id, - id: `${projectId}-domains`, - }), - ); -} + validateProjectIdInQuery(options?.where); + const projectId = parseProjectIdFromWhere(options?.where); + + if (!projectId) { + throw new Error("Query must include eq(collection.projectId, projectId) constraint"); + } + + return trpcClient.deploy.domain.list.query({ projectId }); + }, + getKey: (item) => item.id, + id: "domains", + }), +); diff --git a/web/apps/dashboard/lib/collections/deploy/environments.ts b/web/apps/dashboard/lib/collections/deploy/environments.ts index ed6d467d51..329b5841c0 100644 --- a/web/apps/dashboard/lib/collections/deploy/environments.ts +++ b/web/apps/dashboard/lib/collections/deploy/environments.ts @@ -3,6 +3,7 @@ import { queryCollectionOptions } from "@tanstack/query-db-collection"; import { createCollection } from "@tanstack/react-db"; import { z } from "zod"; import { queryClient, trpcClient } from "../client"; +import { parseProjectIdFromWhere, validateProjectIdInQuery } from "./utils"; const schema = z.object({ id: z.string(), @@ -12,19 +13,34 @@ const schema = z.object({ export type Environment = z.infer; -export function createEnvironmentsCollection(projectId: string) { - if (!projectId) { - throw new Error("projectId is required to create environments collection"); - } +/** + * Global environments collection. + * + * IMPORTANT: All queries MUST filter by projectId: + * .where(({ environment }) => eq(environment.projectId, projectId)) + */ +export const environments = createCollection( + queryCollectionOptions({ + queryClient, + queryKey: (opts) => { + const projectId = parseProjectIdFromWhere(opts.where); + return projectId ? ["environments", projectId] : ["environments"]; + }, + syncMode: "on-demand", + retry: 3, + queryFn: async (ctx) => { + const options = ctx.meta?.loadSubsetOptions; - return createCollection( - queryCollectionOptions({ - queryClient, - queryKey: [projectId, "environments"], - retry: 3, - queryFn: () => trpcClient.deploy.environment.list.query({ projectId }), - getKey: (item) => item.id, - id: `${projectId}-environments`, - }), - ); -} + validateProjectIdInQuery(options?.where); + const projectId = parseProjectIdFromWhere(options?.where); + + if (!projectId) { + throw new Error("Query must include eq(collection.projectId, projectId) constraint"); + } + + return trpcClient.deploy.environment.list.query({ projectId }); + }, + getKey: (item) => item.id, + id: "environments", + }), +); diff --git a/web/apps/dashboard/lib/collections/deploy/projects.ts b/web/apps/dashboard/lib/collections/deploy/projects.ts index 037a2b8825..4d73912cc5 100644 --- a/web/apps/dashboard/lib/collections/deploy/projects.ts +++ b/web/apps/dashboard/lib/collections/deploy/projects.ts @@ -39,7 +39,7 @@ export const createProjectRequestSchema = z.object({ export type Project = z.infer; export type CreateProjectRequestSchema = z.infer; -export const projects = createCollection( +export const projects = createCollection( queryCollectionOptions({ queryClient, queryKey: ["projects"], diff --git a/web/apps/dashboard/lib/collections/deploy/utils.ts b/web/apps/dashboard/lib/collections/deploy/utils.ts new file mode 100644 index 0000000000..f37798fd1b --- /dev/null +++ b/web/apps/dashboard/lib/collections/deploy/utils.ts @@ -0,0 +1,81 @@ +/** + * Parses projectId from where expression. + * + * Structure: + * - Direct: { name: "eq", type: "func", args: [{ path: ["projectId"], type: "ref" }, { value: "proj_xxx", type: "val" }] } + * - And: { name: "and", type: "func", args: [eq(...), eq(...)] } + */ +// biome-ignore lint/suspicious/noExplicitAny: safe to leave coz tanstackdb doesn't expose that internal type to outside +export function parseProjectIdFromWhere(where?: any): string | null { + if (!where) { + return null; + } + + // Helper to check if an expression is eq(projectId, value) + // biome-ignore lint/suspicious/noExplicitAny: safe to leave coz tanstackdb doesn't expose that internal type to outside + function isProjectIdEq(expr: any): string | null { + if (expr?.name !== "eq" || expr?.type !== "func" || !Array.isArray(expr?.args)) { + return null; + } + + const [fieldRef, valueRef] = expr.args; + + // Check if first arg is { path: ["projectId"], type: "ref" } + if ( + fieldRef?.type === "ref" && + Array.isArray(fieldRef?.path) && + fieldRef.path.length === 1 && + fieldRef.path[0] === "projectId" + ) { + // Second arg is { value: "proj_xxx", type: "val" } + if (valueRef?.type === "val" && typeof valueRef?.value === "string") { + return valueRef.value; + } + } + + return null; + } + + // Check direct eq(projectId, value) + const directMatch = isProjectIdEq(where); + if (directMatch) { + return directMatch; + } + + // Check and(eq(projectId, value), ...) + if (where?.name === "and" && where?.type === "func" && Array.isArray(where?.args)) { + for (const arg of where.args) { + const match = isProjectIdEq(arg); + if (match) { + return match; + } + } + } + + return null; +} + +/** + * Runtime dev-mode validator for collections. + * Throws helpful error if projectId filter is missing. + * Only active in development mode (process.env.NODE_ENV !== 'production'). + */ +// biome-ignore lint/suspicious/noExplicitAny: safe to leave coz tanstackdb doesn't expose that internal type to outside +export function validateProjectIdInQuery(where?: any): void { + if (process.env.NODE_ENV === "production") { + return; + } + + if (!where) { + throw new Error( + "Deploy collections require projectId filter: .where(({ collection }) => eq(collection.projectId, projectId))", + ); + } + + const projectId = parseProjectIdFromWhere(where); + if (!projectId) { + throw new Error( + "Deploy collections require projectId as first constraint: .where(({ collection }) => eq(collection.projectId, projectId))", + ); + } +} diff --git a/web/apps/dashboard/lib/collections/index.ts b/web/apps/dashboard/lib/collections/index.ts index fee52f0525..7cff5300a2 100644 --- a/web/apps/dashboard/lib/collections/index.ts +++ b/web/apps/dashboard/lib/collections/index.ts @@ -1,7 +1,7 @@ "use client"; -import { createDeploymentsCollection } from "./deploy/deployments"; -import { createDomainsCollection } from "./deploy/domains"; -import { createEnvironmentsCollection } from "./deploy/environments"; +import { deployments } from "./deploy/deployments"; +import { domains } from "./deploy/domains"; +import { environments } from "./deploy/environments"; import { projects } from "./deploy/projects"; import { ratelimitNamespaces } from "./ratelimit/namespaces"; import { ratelimitOverrides } from "./ratelimit/overrides"; @@ -14,71 +14,19 @@ export type { RatelimitNamespace } from "./ratelimit/namespaces"; export type { RatelimitOverride } from "./ratelimit/overrides"; export type { Environment } from "./deploy/environments"; -type ProjectCollections = { - environments: ReturnType; - domains: ReturnType; - deployments: ReturnType; -}; - -class CollectionManager { - private projectCollections = new Map(); - - getProjectCollections(projectId: string): ProjectCollections { - if (!projectId) { - throw new Error("projectId is required"); - } - if (!this.projectCollections.has(projectId)) { - this.projectCollections.set(projectId, { - environments: createEnvironmentsCollection(projectId), - domains: createDomainsCollection(projectId), - deployments: createDeploymentsCollection(projectId), - }); - } - // biome-ignore lint/style/noNonNullAssertion: Its okay - return this.projectCollections.get(projectId)!; - } - - async preloadProject(projectId: string): Promise { - const collections = this.getProjectCollections(projectId); - // Preload all collections in the object - await Promise.all(Object.values(collections).map((collection) => collection.preload())); - } - - async cleanup(projectId: string) { - const collections = this.projectCollections.get(projectId); - if (collections) { - // Cleanup all collections in the object - await Promise.all(Object.values(collections).map((collection) => collection.cleanup())); - this.projectCollections.delete(projectId); - } - } - - async cleanupAll() { - // Clean up all project collections - const projectCleanupPromises = Array.from(this.projectCollections.keys()).map((projectId) => - this.cleanup(projectId), - ); - // Clean up global collections - const globalCleanupPromises = Object.values(collection).map((c) => c.cleanup()); - await Promise.all([...projectCleanupPromises, ...globalCleanupPromises]); - } -} - -export const collectionManager = new CollectionManager(); - // Global collections export const collection = { projects, ratelimitNamespaces, ratelimitOverrides, + environments, + domains, + deployments, } as const; export async function reset() { - await collectionManager.cleanupAll(); + // Clean up all global collections + await Promise.all(Object.values(collection).map((c) => c.cleanup())); // Preload global collections after cleanup - await Promise.all( - Object.values(collection).map(async (c) => { - await c.preload(); - }), - ); + await Promise.all(Object.values(collection).map((c) => c.preload())); } diff --git a/web/apps/dashboard/lib/collections/ratelimit/namespaces.ts b/web/apps/dashboard/lib/collections/ratelimit/namespaces.ts index 659c9d8b8c..392964e541 100644 --- a/web/apps/dashboard/lib/collections/ratelimit/namespaces.ts +++ b/web/apps/dashboard/lib/collections/ratelimit/namespaces.ts @@ -12,7 +12,7 @@ const schema = z.object({ export type RatelimitNamespace = z.infer; -export const ratelimitNamespaces = createCollection( +export const ratelimitNamespaces = createCollection( queryCollectionOptions({ queryClient, queryKey: ["ratelimitNamespaces"], diff --git a/web/apps/dashboard/lib/collections/ratelimit/overrides.ts b/web/apps/dashboard/lib/collections/ratelimit/overrides.ts index 3ae65677b8..b5e96630b2 100644 --- a/web/apps/dashboard/lib/collections/ratelimit/overrides.ts +++ b/web/apps/dashboard/lib/collections/ratelimit/overrides.ts @@ -14,7 +14,7 @@ const schema = z.object({ }); export type RatelimitOverride = z.infer; -export const ratelimitOverrides = createCollection( +export const ratelimitOverrides = createCollection( queryCollectionOptions({ queryClient, queryKey: ["ratelimitOverrides"], diff --git a/web/apps/dashboard/package.json b/web/apps/dashboard/package.json index 89add63ca3..017cf31030 100644 --- a/web/apps/dashboard/package.json +++ b/web/apps/dashboard/package.json @@ -41,8 +41,8 @@ "@tailwindcss/container-queries": "0.1.1", "@tailwindcss/typography": "0.5.12", "@tanstack/query-core": "5.87.1", - "@tanstack/query-db-collection": "0.2.11", - "@tanstack/react-db": "0.1.12", + "@tanstack/query-db-collection": "1.0.22", + "@tanstack/react-db": "0.1.69", "@tanstack/react-query": "4.36.1", "@tanstack/react-table": "8.16.0", "@tanstack/react-virtual": "3.10.9", diff --git a/web/apps/dashboard/tailwind.config.js b/web/apps/dashboard/tailwind.config.js index 1f8695729c..a599152c50 100644 --- a/web/apps/dashboard/tailwind.config.js +++ b/web/apps/dashboard/tailwind.config.js @@ -119,11 +119,16 @@ module.exports = { "background-position": "calc(100% + var(--shiny-width)) 0", }, }, + shimmer: { + "0%": { transform: "translateX(-100%)" }, + "100%": { transform: "translateX(100%)" }, + }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", "shiny-text": "shiny-text 10s infinite", + shimmer: "shimmer 1.2s ease-in-out infinite", }, fontFamily: { sans: ["var(--font-geist-sans)"], diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index ebef8fdfdd..3128958d32 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -130,11 +130,11 @@ importers: specifier: 5.87.1 version: 5.87.1 '@tanstack/query-db-collection': - specifier: 0.2.11 - version: 0.2.11(@tanstack/query-core@5.87.1)(typescript@5.7.3) + specifier: 1.0.22 + version: 1.0.22(@tanstack/query-core@5.87.1)(typescript@5.7.3) '@tanstack/react-db': - specifier: 0.1.12 - version: 0.1.12(react@19.2.4)(typescript@5.7.3) + specifier: 0.1.69 + version: 0.1.69(react@19.2.4)(typescript@5.7.3) '@tanstack/react-query': specifier: 4.36.1 version: 4.36.1(react-dom@19.2.4)(react@19.2.4) @@ -10906,27 +10906,32 @@ packages: tailwindcss: 3.4.3 dev: false - /@tanstack/db-ivm@0.1.2(typescript@5.7.3): - resolution: {integrity: sha512-byXz+tsaVT2Wi/9F6dbZIiTG/L/Kq6tAN+lpQsp5g6URk2Tw/tZ9zB3503UkZpTyHLdKDk23+qiPKiHZCb5efw==} + /@tanstack/db-ivm@0.1.17(typescript@5.7.3): + resolution: {integrity: sha512-DK7vm56CDxNuRAdsbiPs+gITJ+16tUtYgZg3BRTLYKGIDsy8sdIO7sQFq5zl7Y+aIKAPmMAbVp9UjJ75FTtwgQ==} peerDependencies: typescript: '>=4.7' dependencies: fractional-indexing: 3.2.0 - murmurhash-js: 1.0.0 sorted-btree: 1.8.1 typescript: 5.7.3 dev: false - /@tanstack/db@0.1.12(typescript@5.7.3): - resolution: {integrity: sha512-yYg+fLjEZaGh7wXoVA/Mh0JfrjIoaO+hEqqIjj0e7TGH3m0Og89Mv9AGgRWERXwaQ2qeRiAi0YUgGZcMr8UOyw==} + /@tanstack/db@0.5.25(typescript@5.7.3): + resolution: {integrity: sha512-VqVchs6Mm4rw2GyiOkaoD+PJw6lCJT8EI/TzPu8KWZy3QxyOlilpMvEuDTCl0LZdp1iLYlQT1NdgDg0gimV3kQ==} peerDependencies: typescript: '>=4.7' dependencies: '@standard-schema/spec': 1.1.0 - '@tanstack/db-ivm': 0.1.2(typescript@5.7.3) + '@tanstack/db-ivm': 0.1.17(typescript@5.7.3) + '@tanstack/pacer-lite': 0.2.1 typescript: 5.7.3 dev: false + /@tanstack/pacer-lite@0.2.1: + resolution: {integrity: sha512-3PouiFjR4B6x1c969/Pl4ZIJleof1M0n6fNX8NRiC9Sqv1g06CVDlEaXUR4212ycGFyfq4q+t8Gi37Xy+z34iQ==} + engines: {node: '>=18'} + dev: false + /@tanstack/query-core@4.36.1: resolution: {integrity: sha512-DJSilV5+ytBP1FbFcEJovv4rnnm/CokuVvrBEtW/Va9DvuJ3HksbXUJEpI0aV1KtuL4ZoO9AVE6PyNLzF7tLeA==} dev: false @@ -10935,24 +10940,24 @@ packages: resolution: {integrity: sha512-HOFHVvhOCprrWvtccSzc7+RNqpnLlZ5R6lTmngb8aq7b4rc2/jDT0w+vLdQ4lD9bNtQ+/A4GsFXy030Gk4ollA==} dev: false - /@tanstack/query-db-collection@0.2.11(@tanstack/query-core@5.87.1)(typescript@5.7.3): - resolution: {integrity: sha512-p8b7Wa/eRcOUnfsmTQIp3l4L4ryeRAgGIT7e42WRldTSinbjwN9Llku7mzVadGp0CKeGxfRQOusd3UZu5+IxOg==} + /@tanstack/query-db-collection@1.0.22(@tanstack/query-core@5.87.1)(typescript@5.7.3): + resolution: {integrity: sha512-feYfOIA/xgf3S/aWIhq7Oov/RE66M0wMOZUk1+oAHZ3W7x0br7JzRKFYwdTtIMtopFt8tDq3Pt2gtZVZu+S7rA==} peerDependencies: '@tanstack/query-core': ^5.0.0 typescript: '>=4.7' dependencies: '@standard-schema/spec': 1.1.0 - '@tanstack/db': 0.1.12(typescript@5.7.3) + '@tanstack/db': 0.5.25(typescript@5.7.3) '@tanstack/query-core': 5.87.1 typescript: 5.7.3 dev: false - /@tanstack/react-db@0.1.12(react@19.2.4)(typescript@5.7.3): - resolution: {integrity: sha512-yo/F3hcrt+S6rxhuiaKKZ9+C5TUABJsWrrIpCjO4zvELBHuBeBH6C4xr24QbwUpQXEscneQcxQauhQkJVuzggw==} + /@tanstack/react-db@0.1.69(react@19.2.4)(typescript@5.7.3): + resolution: {integrity: sha512-rqhajRK5InIEKT9RABE9zNbYZL5NGkySjGNVANyilu/ADFHV8rhtkMEnhHcbrzv0grIKpcSlx1AvTgJNbbzjkw==} peerDependencies: react: '>=16.8.0' dependencies: - '@tanstack/db': 0.1.12(typescript@5.7.3) + '@tanstack/db': 0.5.25(typescript@5.7.3) react: 19.2.4 use-sync-external-store: 1.6.0(react@19.2.4) transitivePeerDependencies: @@ -19899,10 +19904,6 @@ packages: /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - /murmurhash-js@1.0.0: - resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==} - dev: false - /mute-stream@0.0.8: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} dev: true