diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-datetime/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-datetime/index.tsx index 7ebaec0d38..4eb0dcfdb9 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-datetime/index.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-datetime/index.tsx @@ -5,23 +5,15 @@ import { Button } from "@unkey/ui"; import { useEffect, useState } from "react"; import { useFilters } from "../../../../hooks/use-filters"; -const TITLE_EMPTY_DEFAULT = "Select Time Range"; - export const DeploymentListDatetime = () => { - const [title, setTitle] = useState(TITLE_EMPTY_DEFAULT); + const [title, setTitle] = useState(null); const { filters, updateFilters } = useFilters(); - // If none of the filters are set anymore we should reset the title - // This can happen when the user manually clears a filter in the url - // or in the filter cloud useEffect(() => { - for (const filter of filters) { - if (["startTime", "endTime", "since"].includes(filter.field)) { - return; - } + if (!title) { + setTitle("Last 12 hours"); } - setTitle(TITLE_EMPTY_DEFAULT); - }, [filters]); + }, [title]); const timeValues = filters .filter((f) => ["startTime", "endTime", "since"].includes(f.field)) diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-filters/components/environment-filter.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-filters/components/environment-filter.tsx index 145284e0b9..19a48a393d 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-filters/components/environment-filter.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-filters/components/environment-filter.tsx @@ -1,12 +1,13 @@ +import { useProjectLayout } from "@/app/(app)/projects/[projectId]/layout-provider"; import { FilterCheckbox } from "@/components/logs/checkbox/filter-checkbox"; -import { collection } from "@/lib/collections"; import { useLiveQuery } from "@tanstack/react-db"; import { useFilters } from "../../../../../hooks/use-filters"; export const EnvironmentFilter = () => { const { filters, updateFilters } = useFilters(); + const { collections } = useProjectLayout(); - const environments = useLiveQuery((q) => q.from({ environment: collection.environments })); + const environments = useLiveQuery((q) => q.from({ environment: collections.environments })); return ( { const { filters, updateFilters } = useFilters(); - const queryLLMForStructuredOutput = trpc.deployment.search.useMutation({ + const queryLLMForStructuredOutput = trpc.deploy.deployment.search.useMutation({ onSuccess(data) { if (data?.filters.length === 0 || !data) { toast.error( diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/rollback-dialog.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/rollback-dialog.tsx index 52af6548c6..c1d31ad3d7 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/rollback-dialog.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/rollback-dialog.tsx @@ -40,7 +40,7 @@ export const RollbackDialog = ({ hostname, }: RollbackDialogProps) => { const utils = trpc.useUtils(); - const rollback = trpc.deploy.rollback.useMutation({ + const rollback = trpc.deploy.deployment.rollback.useMutation({ onSuccess: () => { utils.invalidate(); toast.success("Rollback completed", { diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/deployments-list.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/deployments-list.tsx index 535e97e6cf..78daec7272 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/deployments-list.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/deployments-list.tsx @@ -2,17 +2,15 @@ import { VirtualTable } from "@/components/virtual-table/index"; import type { Column } from "@/components/virtual-table/types"; import { useIsMobile } from "@/hooks/use-mobile"; -import { type Deployment, type Environment, collection } from "@/lib/collections"; +import type { Deployment, Environment } from "@/lib/collections"; import { shortenId } from "@/lib/shorten-id"; -import { eq, gt, gte, lte, or, useLiveQuery } from "@tanstack/react-db"; import { BookBookmark, Cloud, CodeBranch, Cube } from "@unkey/icons"; import { Button, Empty, TimestampInfo } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; -import ms from "ms"; import dynamic from "next/dynamic"; import { useMemo, useState } from "react"; -import type { DeploymentListFilterField } from "../../filters.schema"; -import { useFilters } from "../../hooks/use-filters"; +import { Avatar } from "../../../details/active-deployment-card/git-avatar"; +import { useDeployments } from "../../hooks/use-deployments"; import { DeploymentStatusBadge } from "./components/deployment-status-badge"; import { EnvStatusBadge } from "./components/env-status-badge"; import { @@ -41,125 +39,15 @@ const DeploymentListTableActions = dynamic( const COMPACT_BREAKPOINT = 1200; -type Props = { - projectId: string; -}; - -export const DeploymentsList = ({ projectId }: Props) => { - 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); - }); - - const activeDeploymentId = project.data.at(0)?.activeDeploymentId; - - const activeDeployment = useLiveQuery( - (q) => - q - .from({ deployment: collection.deployments }) - .where(({ deployment }) => eq(deployment.id, activeDeploymentId)) - .orderBy(({ deployment }) => deployment.createdAt, "desc") - .limit(1), - [activeDeploymentId], - ); - - 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: collection.environments }); - - for (const filter of filters) { - if (filter.field === "environment") { - environments = environments.where(({ environment }) => - eq(environment.slug, filter.value), - ); - } - } - - let query = q - .from({ deployment: collection.deployments }) - - .where(({ deployment }) => eq(deployment.projectId, projectId)); - - // add additional where clauses based on filters. - // All of these are a locical AND - - const groupedFilters = filters.reduce( - (acc, f) => { - if (!acc[f.field]) { - acc[f.field] = []; - } - acc[f.field].push(f.value); - return acc; - }, - {} as Record, - ); - for (const [field, values] of Object.entries(groupedFilters)) { - // this is kind of dumb, but `or`s type doesn't allow spreaded args without - // specifying the first two - const [v1, v2, ...rest] = values; - const f = field as DeploymentListFilterField; // I want some typesafety - switch (f) { - case "status": - query = query.where(({ deployment }) => - or( - eq(deployment.status, v1), - eq(deployment.status, v2), - ...rest.map((value) => eq(deployment.status, value)), - ), - ); - break; - case "branch": - query = query.where(({ deployment }) => - or( - eq(deployment.gitBranch, v1), - eq(deployment.gitBranch, v2), - ...rest.map((value) => eq(deployment.gitBranch, value)), - ), - ); - break; - case "environment": - // We already filtered - break; - case "since": - query = query.where(({ deployment }) => - gt(deployment.createdAt, Date.now() - ms(values.at(0) as string)), - ); - - break; - case "startTime": - query = query.where(({ deployment }) => gte(deployment.createdAt, values.at(0))); - break; - case "endTime": - query = query.where(({ deployment }) => lte(deployment.createdAt, values.at(0))); - break; - default: - break; - } - } - - return query - .rightJoin({ environment: environments }, ({ environment, deployment }) => - eq(environment.id, deployment.environmentId), - ) - .orderBy(({ deployment }) => deployment.createdAt, "desc") - .limit(100); - }, - [projectId, filters], - ); - +export const DeploymentsList = () => { const [selectedDeployment, setSelectedDeployment] = useState<{ deployment: Deployment; environment?: Environment; } | null>(null); const isCompactView = useIsMobile({ breakpoint: COMPACT_BREAKPOINT }); + const { activeDeployment, deployments } = useDeployments(); + const columns: Column<{ deployment: Deployment; environment?: Environment; @@ -185,7 +73,7 @@ export const DeploymentsList = ({ projectId }: Props) => { ); return (
-
+
{iconContainer}
@@ -197,7 +85,7 @@ export const DeploymentsList = ({ projectId }: Props) => { > {shortenId(deployment.id)}
- {deployment.id === activeDeploymentId ? ( + {deployment.id === activeDeployment.data.at(0)?.id ? ( ) : null}
@@ -291,7 +179,7 @@ export const DeploymentsList = ({ projectId }: Props) => { ); return (
-
+
{iconContainer}
@@ -305,7 +193,7 @@ export const DeploymentsList = ({ projectId }: Props) => {
- {deployment.gitCommitSha} + {deployment.gitCommitSha?.slice(0, 7)}
@@ -322,12 +210,12 @@ export const DeploymentsList = ({ projectId }: Props) => { render: ({ deployment }: { deployment: Deployment }) => { return (
-
- Author + +
@@ -369,10 +257,9 @@ export const DeploymentsList = ({ projectId }: Props) => { render: ({ deployment }: { deployment: Deployment }) => { return (
- Author {deployment.gitCommitAuthorName} @@ -403,7 +290,7 @@ export const DeploymentsList = ({ projectId }: Props) => { }, }, ]; - }, [selectedDeployment?.deployment.id, isCompactView, activeDeployment, activeDeploymentId]); + }, [selectedDeployment?.deployment.id, isCompactView, activeDeployment]); return ( { + const { projectId, collections } = useProjectLayout(); + 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); + }); + const liveDeploymentId = project.data.at(0)?.liveDeploymentId; + const activeDeployment = useLiveQuery( + (q) => + q + .from({ deployment: collections.deployments }) + .where(({ deployment }) => eq(deployment.id, liveDeploymentId)) + .orderBy(({ deployment }) => deployment.createdAt, "desc") + .limit(1), + [liveDeploymentId], + ); + 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 }); + + for (const filter of filters) { + if (filter.field === "environment") { + environments = environments.where(({ environment }) => + eq(environment.slug, filter.value), + ); + } + } + + let query = q + .from({ deployment: collections.deployments }) + + .where(({ deployment }) => eq(deployment.projectId, projectId)); + + // add additional where clauses based on filters. + // All of these are a locical AND + + const groupedFilters = filters.reduce( + (acc, f) => { + if (!acc[f.field]) { + acc[f.field] = []; + } + acc[f.field].push(f.value); + return acc; + }, + {} as Record, + ); + for (const [field, values] of Object.entries(groupedFilters)) { + // this is kind of dumb, but `or`s type doesn't allow spreaded args without + // specifying the first two + const [v1, v2, ...rest] = values; + const f = field as DeploymentListFilterField; // I want some typesafety + switch (f) { + case "status": + query = query.where(({ deployment }) => + or( + eq(deployment.status, v1), + eq(deployment.status, v2), + ...rest.map((value) => eq(deployment.status, value)), + ), + ); + break; + case "branch": + query = query.where(({ deployment }) => + or( + eq(deployment.gitBranch, v1), + eq(deployment.gitBranch, v2), + ...rest.map((value) => eq(deployment.gitBranch, value)), + ), + ); + break; + case "environment": + // We already filtered + break; + case "since": + query = query.where(({ deployment }) => + gt(deployment.createdAt, Date.now() - ms(values.at(0) as string)), + ); + + break; + case "startTime": + query = query.where(({ deployment }) => gte(deployment.createdAt, values.at(0))); + break; + case "endTime": + query = query.where(({ deployment }) => lte(deployment.createdAt, values.at(0))); + break; + default: + break; + } + } + + return query + .rightJoin({ environment: environments }, ({ environment, deployment }) => + eq(environment.id, deployment.environmentId), + ) + .orderBy(({ deployment }) => deployment.createdAt, "desc") + .limit(100); + }, + [projectId, filters], + ); + + return { + deployments, + activeDeployment, + activeDeploymentId: liveDeploymentId, + }; +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/page.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/page.tsx index 6e8cfb947e..d7b30eeb33 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/page.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/page.tsx @@ -1,18 +1,23 @@ "use client"; - -import { useParams } from "next/navigation"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { useProjectLayout } from "../layout-provider"; import { DeploymentsListControlCloud } from "./components/control-cloud"; import { DeploymentsListControls } from "./components/controls"; import { DeploymentsList } from "./components/table/deployments-list"; export default function Deployments() { - // biome-ignore lint/style/noNonNullAssertion: shut up nextjs - const { projectId } = useParams<{ projectId: string }>()!; + const { isDetailsOpen } = useProjectLayout(); + return ( -
+
- +
); } diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/filter-button.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/filter-button.tsx index 3810701fa5..0f48027318 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/filter-button.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/filter-button.tsx @@ -27,7 +27,7 @@ export const FilterButton = ({ > {label} -
+
{count}
diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/git-avatar.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/git-avatar.tsx new file mode 100644 index 0000000000..72c9d0ed49 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/git-avatar.tsx @@ -0,0 +1,29 @@ +import { User } from "@unkey/icons"; +import { useState } from "react"; + +type AvatarProps = { + src: string | null | undefined; + alt: string; + className?: string; +}; + +export function Avatar({ src, alt, className = "size-5" }: AvatarProps) { + const [hasError, setHasError] = useState(false); + + if (!src || hasError) { + return ( +
+ +
+ ); + } + + return ( + {alt} setHasError(true)} + /> + ); +} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/hooks/use-deployment-logs.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/hooks/use-deployment-logs.tsx index 0edf0e2806..9a51131362 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/hooks/use-deployment-logs.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/hooks/use-deployment-logs.tsx @@ -50,7 +50,7 @@ export function useDeploymentLogs({ const scrollRef = useRef(null); // Fetch logs via tRPC - const { data: logsData, isLoading } = trpc.deployment.buildLogs.useQuery({ + const { data: logsData, isLoading } = trpc.deploy.deployment.buildLogs.useQuery({ deploymentId, }); diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/index.tsx index 8104ee0c38..ff86f6640d 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/index.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/index.tsx @@ -1,6 +1,5 @@ "use client"; -import { collection } from "@/lib/collections"; import { eq, useLiveQuery } from "@tanstack/react-db"; import { ChevronDown, @@ -15,7 +14,9 @@ import { } from "@unkey/icons"; import { Badge, Button, Card, CopyButton, Input, TimestampInfo } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; +import { useProjectLayout } from "../../layout-provider"; import { FilterButton } from "./filter-button"; +import { Avatar } from "./git-avatar"; import { useDeploymentLogs } from "./hooks/use-deployment-logs"; import { InfoChip } from "./info-chip"; import { ActiveDeploymentCardSkeleton } from "./skeleton"; @@ -68,9 +69,10 @@ type Props = { }; export const ActiveDeploymentCard: React.FC = ({ deploymentId }) => { + const { collections } = useProjectLayout(); const { data } = useLiveQuery((q) => q - .from({ deployment: collection.deployments }) + .from({ deployment: collections.deployments }) .where(({ deployment }) => eq(deployment.id, deploymentId)), ); @@ -103,7 +105,7 @@ export const ActiveDeploymentCard: React.FC = ({ deploymentId }) => {
{deployment.id}
-
TODO
+
{deployment.gitCommitMessage}
@@ -116,7 +118,7 @@ export const ActiveDeploymentCard: React.FC = ({ deploymentId }) => {
Created by - TODO + {deployment.gitCommitAuthorName} @@ -139,10 +141,14 @@ export const ActiveDeploymentCard: React.FC = ({ deploymentId }) => { />
- {deployment.gitBranch} + + {deployment.gitBranch} + - {deployment.gitCommitSha} + + {(deployment.gitCommitSha ?? "").slice(0, 7)} +
diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/card.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/card.tsx similarity index 100% rename from apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/card.tsx rename to apps/dashboard/app/(app)/projects/[projectId]/details/card.tsx diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/hooks/use-env-var-manager.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/hooks/use-env-var-manager.tsx index 4e84dc7d99..145bb83545 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/hooks/use-env-var-manager.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/hooks/use-env-var-manager.tsx @@ -7,7 +7,7 @@ type UseEnvVarsManagerProps = { }; export function useEnvVarsManager({ projectId, environment }: UseEnvVarsManagerProps) { - const { data } = trpc.environmentVariables.list.useQuery({ projectId }); + const { data } = trpc.deploy.environment.list_dummy.useQuery({ projectId }); const envVars = data?.[environment] ?? []; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/project-details-expandables/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/project-details-expandables/index.tsx index 1e21004a46..972ef07cb6 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/details/project-details-expandables/index.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/project-details-expandables/index.tsx @@ -3,6 +3,7 @@ import { eq, useLiveQuery } from "@tanstack/react-db"; import { Book2, Cube, DoubleChevronRight } from "@unkey/icons"; import { Button, InfoTooltip } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; +import { useProjectLayout } from "../../layout-provider"; import { DetailSection } from "./detail-section"; import { createDetailSections } from "./sections"; @@ -19,13 +20,13 @@ export const ProjectDetailsExpandable = ({ onClose, projectId, }: ProjectDetailsExpandableProps) => { + const { collections } = useProjectLayout(); const query = useLiveQuery((q) => q .from({ project: collection.projects }) .where(({ project }) => eq(project.id, projectId)) - - .join({ deployment: collection.deployments }, ({ deployment, project }) => - eq(deployment.id, project.activeDeploymentId), + .join({ deployment: collections.deployments }, ({ deployment, project }) => + eq(deployment.id, project.liveDeploymentId), ) .orderBy(({ project }) => project.id, "asc") .limit(1), diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/project-details-expandables/sections.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/project-details-expandables/sections.tsx index 6d999415e7..8a497e61d7 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/details/project-details-expandables/sections.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/project-details-expandables/sections.tsx @@ -16,6 +16,7 @@ import { } from "@unkey/icons"; import { Badge, TimestampInfo } from "@unkey/ui"; import type { ReactNode } from "react"; +import { Avatar } from "../active-deployment-card/git-avatar"; export type DetailItem = { icon: ReactNode; @@ -45,12 +46,18 @@ export const createDetailSections = (details: Deployment): DetailSection[] => [ { icon: , label: "Branch", - content: {details.gitBranch}, + content: ( + {details.gitBranch} + ), }, { icon: , label: "Commit", - content: {details.gitCommitSha}, + content: ( + + {(details.gitCommitSha ?? "").slice(0, 7)} + + ), }, { icon: , @@ -66,10 +73,9 @@ export const createDetailSections = (details: Deployment): DetailSection[] => [ label: "Author", content: (
- {details.gitCommitAuthorUsername {details.gitCommitAuthorUsername}
@@ -124,7 +130,8 @@ export const createDetailSections = (details: Deployment): DetailSection[] => [ label: "CPU", content: (
- {details.runtimeConfig.cpus}vCPUs + {details.runtimeConfig.cpus} + vCPUs
), }, @@ -133,7 +140,8 @@ export const createDetailSections = (details: Deployment): DetailSection[] => [ label: "Memory", content: (
- {details.runtimeConfig.memory}mb + {details.runtimeConfig.memory} + mb
), }, diff --git a/apps/dashboard/app/(app)/projects/[projectId]/diff/[...compare]/page.tsx b/apps/dashboard/app/(app)/projects/[projectId]/diff/[...compare]/page.tsx index f36852e6b2..26ff6cdbc4 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/diff/[...compare]/page.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/diff/[...compare]/page.tsx @@ -1,12 +1,12 @@ "use client"; -import { collection } from "@/lib/collections"; import { trpc } from "@/lib/trpc/client"; import { eq, useLiveQuery } from "@tanstack/react-db"; import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@unkey/ui"; import { AlertCircle, ArrowLeft, GitCompare, Loader } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; +import { useProjectLayout } from "../../layout-provider"; import { DiffViewer } from "./components/client"; interface Props { @@ -20,6 +20,7 @@ interface Props { } export default function DiffPage({ params }: Props) { + const { collections } = useProjectLayout(); const router = useRouter(); const [fromDeploymentId, toDeploymentId] = params.compare; const [selectedFromDeployment, setSelectedFromDeployment] = useState( @@ -33,9 +34,9 @@ export default function DiffPage({ params }: Props) { const deployments = useLiveQuery((q) => q - .from({ deployment: collection.deployments }) + .from({ deployment: collections.deployments }) .where(({ deployment }) => eq(deployment.projectId, params.projectId)) - .join({ environment: collection.environments }, ({ environment, deployment }) => + .join({ environment: collections.environments }, ({ environment, deployment }) => eq(environment.id, deployment.environmentId), ) .orderBy(({ deployment }) => deployment.createdAt, "desc") @@ -47,7 +48,7 @@ export default function DiffPage({ params }: Props) { data: diffData, isLoading: diffLoading, error: diffError, - } = trpc.deployment.getOpenApiDiff.useQuery( + } = trpc.deploy.deployment.getOpenApiDiff.useQuery( { oldDeploymentId: selectedFromDeployment, newDeploymentId: selectedToDeployment, diff --git a/apps/dashboard/app/(app)/projects/[projectId]/diff/page.tsx b/apps/dashboard/app/(app)/projects/[projectId]/diff/page.tsx index 1020b9ac66..08a384ddd0 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/diff/page.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/diff/page.tsx @@ -1,13 +1,14 @@ "use client"; -import { collection } from "@/lib/collections"; import { eq, useLiveQuery } from "@tanstack/react-db"; import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@unkey/ui"; import { ArrowLeft, GitBranch, GitCommit, GitCompare, Globe, Tag } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; import { useState } from "react"; +import { useProjectLayout } from "../layout-provider"; export default function DiffSelectionPage(): JSX.Element { + const { collections } = useProjectLayout(); const params = useParams(); const router = useRouter(); const projectId = params?.projectId as string; @@ -18,9 +19,9 @@ export default function DiffSelectionPage(): JSX.Element { // Fetch all deployments for this project const deployments = useLiveQuery((q) => q - .from({ deployment: collection.deployments }) + .from({ deployment: collections.deployments }) .where(({ deployment }) => eq(deployment.projectId, params?.projectId)) - .join({ environment: collection.environments }, ({ environment, deployment }) => + .join({ environment: collections.environments }, ({ environment, deployment }) => eq(environment.id, deployment.environmentId), ) .orderBy(({ deployment }) => deployment.createdAt, "desc") diff --git a/apps/dashboard/app/(app)/projects/[projectId]/layout-provider.tsx b/apps/dashboard/app/(app)/projects/[projectId]/layout-provider.tsx index 3a0ce064f7..238cdf0d91 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/layout-provider.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/layout-provider.tsx @@ -1,10 +1,11 @@ +import type { collectionManager } from "@/lib/collections"; import { createContext, useContext } from "react"; type ProjectLayoutContextType = { isDetailsOpen: boolean; setIsDetailsOpen: (open: boolean) => void; - projectId: string; + collections: ReturnType; }; export const ProjectLayoutContext = createContext(null); diff --git a/apps/dashboard/app/(app)/projects/[projectId]/layout.tsx b/apps/dashboard/app/(app)/projects/[projectId]/layout.tsx index 6a1756bf94..f36b35cc83 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/layout.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/layout.tsx @@ -1,4 +1,5 @@ "use client"; +import { collectionManager } from "@/lib/collections"; import { DoubleChevronLeft } from "@unkey/icons"; import { Button, InfoTooltip } from "@unkey/ui"; import { useState } from "react"; @@ -24,7 +25,9 @@ type ProjectLayoutProps = { const ProjectLayout = ({ projectId, children }: ProjectLayoutProps) => { const [tableDistanceToTop, setTableDistanceToTop] = useState(0); - const [isDetailsOpen, setIsDetailsOpen] = useState(false); + const [isDetailsOpen, setIsDetailsOpen] = useState(true); + + const collections = collectionManager.getProjectCollections(projectId); return ( { isDetailsOpen, setIsDetailsOpen, projectId, + collections, }} >
diff --git a/apps/dashboard/app/(app)/projects/[projectId]/page.tsx b/apps/dashboard/app/(app)/projects/[projectId]/page.tsx index 062e694866..86a9ed5aad 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/page.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/page.tsx @@ -11,12 +11,9 @@ import { EnvironmentVariablesSection } from "./details/env-variables-section"; import { useProjectLayout } from "./layout-provider"; export default function ProjectDetails() { - const { isDetailsOpen, projectId } = useProjectLayout(); - - const domains = useLiveQuery((q) => - q.from({ domain: collection.domains }).where(({ domain }) => eq(domain.projectId, projectId)), - ); + const { isDetailsOpen, projectId, collections } = useProjectLayout(); + const domains = useLiveQuery((q) => q.from({ domain: collections.domains })); const projects = useLiveQuery((q) => q.from({ project: collection.projects }).where(({ project }) => eq(project.id, projectId)), ); @@ -41,13 +38,13 @@ export default function ProjectDetails() { )} >
- {project.activeDeploymentId ? ( + {project.liveDeploymentId ? (
} title="Active Deployment" /> - +
) : null} diff --git a/apps/dashboard/app/(app)/projects/_components/create-project/create-project-dialog.tsx b/apps/dashboard/app/(app)/projects/_components/create-project/create-project-dialog.tsx index 8608b92641..9f2abffe54 100644 --- a/apps/dashboard/app/(app)/projects/_components/create-project/create-project-dialog.tsx +++ b/apps/dashboard/app/(app)/projects/_components/create-project/create-project-dialog.tsx @@ -1,17 +1,17 @@ "use client"; - import { NavbarActionButton } from "@/components/navigation/action-button"; import { Navbar } from "@/components/navigation/navbar"; +import { collection } from "@/lib/collections"; +import { + type CreateProjectRequestSchema, + createProjectRequestSchema, +} from "@/lib/collections/deploy/projects"; import { zodResolver } from "@hookform/resolvers/zod"; +import { DuplicateKeyError } from "@tanstack/react-db"; import { Plus } from "@unkey/icons"; -import { Button, DialogContainer, FormInput, toast } from "@unkey/ui"; +import { Button, DialogContainer, FormInput } from "@unkey/ui"; import { useState } from "react"; import { useForm } from "react-hook-form"; -import type { z } from "zod"; -import { createProjectSchema } from "./create-project.schema"; -import { useCreateProject } from "./use-create-project"; - -type FormValues = z.infer; export const CreateProjectDialog = () => { const [isModalOpen, setIsModalOpen] = useState(false); @@ -20,34 +20,43 @@ export const CreateProjectDialog = () => { register, handleSubmit, setValue, + setError, reset, - formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(createProjectSchema), + formState: { errors, isSubmitting, isValid }, + } = useForm({ + resolver: zodResolver(createProjectRequestSchema), defaultValues: { name: "", slug: "", gitRepositoryUrl: "", }, + mode: "onChange", }); - const createProject = useCreateProject((data) => { - toast.success("Project has been created", { - description: `${data.name} is ready to use`, - }); - reset(); - setIsModalOpen(false); - }); - - const onSubmitForm = async (values: FormValues) => { + const onSubmitForm = async (values: CreateProjectRequestSchema) => { try { - await createProject.mutateAsync({ + const tx = collection.projects.insert({ name: values.name, slug: values.slug, - gitRepositoryUrl: values.gitRepositoryUrl ?? null, + gitRepositoryUrl: values.gitRepositoryUrl || null, + liveDeploymentId: null, + updatedAt: null, + id: "will-be-replace-by-server", }); + await tx.isPersisted.promise; + + reset(); + setIsModalOpen(false); } catch (error) { - console.error("Form submission error:", error); + if (error instanceof DuplicateKeyError) { + setError("slug", { + type: "custom", + message: "Project with this slug already exists", + }); + } else { + console.error("Form submission error:", error); + // The collection's onInsert will handle showing error toasts + } } }; @@ -59,7 +68,6 @@ export const CreateProjectDialog = () => { .replace(/\s+/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); - setValue("slug", slug); }; @@ -91,8 +99,8 @@ export const CreateProjectDialog = () => { form="project-form" variant="primary" size="xlg" - disabled={isSubmitting || createProject.isLoading} - loading={isSubmitting || createProject.isLoading} + disabled={isSubmitting || !isValid} + loading={isSubmitting} className="w-full rounded-lg" > Create Project @@ -119,6 +127,7 @@ export const CreateProjectDialog = () => { })} placeholder="My Awesome Project" /> + { {...register("slug")} placeholder="my-awesome-project" /> + void) => { - const project = trpc.project.create.useMutation({ - onSuccess(data) { - onSuccess(data); - }, - onError(err) { - if (err.data?.code === "NOT_FOUND") { - toast.error("Project Creation Failed", { - description: "Unable to find the workspace. Please refresh and try again.", - }); - } else if (err.data?.code === "CONFLICT") { - toast.error("Project Already Exists", { - description: err.message || "A project with this slug already exists in your workspace.", - }); - } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { - toast.error("Server Error", { - description: - "We encountered an issue while creating your project. Please try again later or contact support at support@unkey.dev", - }); - } else if (err.data?.code === "BAD_REQUEST") { - toast.error("Invalid Configuration", { - description: `Please check your project settings. ${err.message || ""}`, - }); - } else if (err.data?.code === "FORBIDDEN") { - toast.error("Permission Denied", { - description: - err.message || "You don't have permission to create projects in this workspace.", - }); - } else { - toast.error("Failed to Create Project", { - description: err.message || "An unexpected error occurred. Please try again later.", - action: { - label: "Contact Support", - onClick: () => window.open("https://support.unkey.dev", "_blank"), - }, - }); - } - }, - }); - return project; -}; diff --git a/apps/dashboard/app/(app)/projects/_components/list/index.tsx b/apps/dashboard/app/(app)/projects/_components/list/index.tsx index 1ed50c638e..cd6562aff6 100644 --- a/apps/dashboard/app/(app)/projects/_components/list/index.tsx +++ b/apps/dashboard/app/(app)/projects/_components/list/index.tsx @@ -1,7 +1,8 @@ -import { collection } from "@/lib/collections"; -import { useLiveQuery } from "@tanstack/react-db"; +import { collection, collectionManager } from "@/lib/collections"; +import { ilike, useLiveQuery } from "@tanstack/react-db"; import { BookBookmark, Dots } from "@unkey/icons"; import { Button, Empty } from "@unkey/ui"; +import { useProjectsFilters } from "../hooks/use-projects-filters"; import { ProjectActions } from "./project-actions"; import { ProjectCard } from "./projects-card"; import { ProjectCardSkeleton } from "./projects-card-skeleton"; @@ -9,11 +10,39 @@ import { ProjectCardSkeleton } from "./projects-card-skeleton"; const MAX_SKELETON_COUNT = 8; export const ProjectsList = () => { - const projects = useLiveQuery((q) => - q.from({ project: collection.projects }).orderBy(({ project }) => project.updatedAt, "desc"), + const { filters } = useProjectsFilters(); + const projectName = filters.find((f) => f.field === "query")?.value ?? ""; + + const projects = useLiveQuery( + (q) => + q + .from({ project: collection.projects }) + .orderBy(({ project }) => project.updatedAt, "desc") + .where(({ project }) => ilike(project.name, `%${projectName}%`)), + [projectName], ); - if (projects.isLoading) { + // Get deployments and domains for each project + const deploymentQueries = projects.data.map((project) => { + const collections = collectionManager.getProjectCollections(project.id); + return useLiveQuery((q) => q.from({ deployment: collections.deployments }), [project.id]); + }); + + const domainQueries = projects.data.map((project) => { + const collections = collectionManager.getProjectCollections(project.id); + return useLiveQuery((q) => q.from({ domain: collections.domains }), [project.id]); + }); + + // Flatten the results + const allDeployments = deploymentQueries.flatMap((query) => query.data || []); + const allDomains = domainQueries.flatMap((query) => query.data || []); + + const isLoading = + projects.isLoading || + deploymentQueries.some((q) => q.isLoading) || + domainQueries.some((q) => q.isLoading); + + if (isLoading) { return (
{
{projects.data.map((project) => { + // Find active deployment and associated domain for this project + const activeDeployment = project.liveDeploymentId + ? allDeployments.find((d) => d.id === project.liveDeploymentId) + : null; + + // Find domain for this project + const projectDomain = allDomains.find((d) => d.projectId === project.id); + + // Extract deployment regions for display + const regions = activeDeployment?.runtimeConfig?.regions?.map((r) => r.region) ?? []; + return ( 0 ? regions : ["No deployments"]} repository={project.gitRepositoryUrl || undefined} actions={ diff --git a/apps/dashboard/app/(app)/projects/_components/list/projects-card.tsx b/apps/dashboard/app/(app)/projects/_components/list/projects-card.tsx index c097b496db..9cb0edb8c0 100644 --- a/apps/dashboard/app/(app)/projects/_components/list/projects-card.tsx +++ b/apps/dashboard/app/(app)/projects/_components/list/projects-card.tsx @@ -1,5 +1,5 @@ import { CodeBranch, Cube, User } from "@unkey/icons"; -import { InfoTooltip, Loading } from "@unkey/ui"; +import { InfoTooltip, Loading, TimestampInfo } from "@unkey/ui"; import Link from "next/link"; import type { ReactNode } from "react"; import { useCallback, useState } from "react"; @@ -9,7 +9,7 @@ type ProjectCardProps = { name: string; domain: string; commitTitle: string; - commitDate: string; + commitTimestamp?: number; branch: string; author: string; regions: string[]; @@ -22,7 +22,7 @@ export const ProjectCard = ({ name, domain, commitTitle, - commitDate, + commitTimestamp, branch, author, regions, @@ -58,7 +58,7 @@ export const ProjectCard = ({ {/*Top Section > Project Name*/} {name} @@ -90,7 +90,11 @@ export const ProjectCard = ({
- {commitDate} on + {commitTimestamp ? ( + + ) : ( + No deployments + )} {branch} diff --git a/apps/dashboard/components/list-search-input.tsx b/apps/dashboard/components/list-search-input.tsx index 87368e8528..5a34b3d5f9 100644 --- a/apps/dashboard/components/list-search-input.tsx +++ b/apps/dashboard/components/list-search-input.tsx @@ -26,13 +26,11 @@ type Props = { }; const MAX_QUERY_LENGTH = 120; -const DEFAULT_DEBOUNCE = 300; const DEFAULT_PLACEHOLDER = "Search..."; export const ListSearchInput = ({ useFiltersHook, placeholder = DEFAULT_PLACEHOLDER, - debounceTime = DEFAULT_DEBOUNCE, className, }: Props) => { const { filters, updateFilters } = useFiltersHook(); @@ -98,10 +96,7 @@ export const ListSearchInput = ({ clearTimeout(debounceRef.current); } - // Set new debounce - debounceRef.current = setTimeout(() => { - updateQuery(value); - }, debounceTime); + updateQuery(value); }; const handleClear = () => { diff --git a/apps/dashboard/lib/collections/deploy/deployments.ts b/apps/dashboard/lib/collections/deploy/deployments.ts new file mode 100644 index 0000000000..163b82c7ba --- /dev/null +++ b/apps/dashboard/lib/collections/deploy/deployments.ts @@ -0,0 +1,56 @@ +"use client"; +import { queryCollectionOptions } from "@tanstack/query-db-collection"; +import { createCollection } from "@tanstack/react-db"; +import { z } from "zod"; +import { queryClient, trpcClient } from "../client"; + +const schema = z.object({ + id: z.string(), + projectId: z.string(), + environmentId: z.string(), + // Git information + // TEMP: Git fields as non-nullable for UI development with mock data + // TODO: Convert to nullable (.nullable()) when real git integration is added + // In production, deployments may not have git metadata if triggered manually + gitCommitSha: z.string(), + gitBranch: z.string(), + gitCommitMessage: z.string(), + gitCommitAuthorName: z.string(), + gitCommitAuthorEmail: z.string(), + gitCommitAuthorUsername: z.string(), + gitCommitAuthorAvatarUrl: z.string(), + gitCommitTimestamp: z.number().int(), + // Immutable configuration snapshot + runtimeConfig: z.object({ + regions: z.array( + z.object({ + region: z.string(), + vmCount: z.number().min(1).max(100), + }), + ), + cpus: z.number().min(1).max(16), + memory: z.number().min(1).max(1024), + }), + // Deployment status + status: z.enum(["pending", "building", "deploying", "network", "ready", "failed"]), + createdAt: z.number(), +}); + +export type Deployment = z.infer; + +export function createDeploymentsCollection(projectId: string) { + if (!projectId) { + throw new Error("projectId is required to create deployments collection"); + } + + return createCollection( + queryCollectionOptions({ + queryClient, + queryKey: [projectId, "deployments"], + retry: 3, + queryFn: () => trpcClient.deploy.deployment.list.query({ projectId }), + getKey: (item) => item.id, + id: `${projectId}-deployments`, + }), + ); +} diff --git a/apps/dashboard/lib/collections/deploy/domains.ts b/apps/dashboard/lib/collections/deploy/domains.ts new file mode 100644 index 0000000000..a2e1f1e897 --- /dev/null +++ b/apps/dashboard/lib/collections/deploy/domains.ts @@ -0,0 +1,31 @@ +"use client"; +import { queryCollectionOptions } from "@tanstack/query-db-collection"; +import { createCollection } from "@tanstack/react-db"; +import { z } from "zod"; +import { queryClient, trpcClient } from "../client"; + +const schema = z.object({ + id: z.string(), + domain: z.string(), + type: z.enum(["custom", "wildcard"]), + projectId: z.string().nullable(), +}); + +export type Domain = z.infer; + +export function createDomainsCollection(projectId: string) { + if (!projectId) { + throw new Error("projectId is required to create domains collection"); + } + + return createCollection( + queryCollectionOptions({ + queryClient, + queryKey: [projectId, "domains"], + retry: 3, + queryFn: () => trpcClient.deploy.domain.list.query({ projectId }), + getKey: (item) => item.id, + id: `${projectId}-domains`, + }), + ); +} diff --git a/apps/dashboard/lib/collections/deploy/environments.ts b/apps/dashboard/lib/collections/deploy/environments.ts new file mode 100644 index 0000000000..ed6d467d51 --- /dev/null +++ b/apps/dashboard/lib/collections/deploy/environments.ts @@ -0,0 +1,30 @@ +"use client"; +import { queryCollectionOptions } from "@tanstack/query-db-collection"; +import { createCollection } from "@tanstack/react-db"; +import { z } from "zod"; +import { queryClient, trpcClient } from "../client"; + +const schema = z.object({ + id: z.string(), + projectId: z.string(), + slug: z.string(), +}); + +export type Environment = z.infer; + +export function createEnvironmentsCollection(projectId: string) { + if (!projectId) { + throw new Error("projectId is required to create environments collection"); + } + + return createCollection( + queryCollectionOptions({ + queryClient, + queryKey: [projectId, "environments"], + retry: 3, + queryFn: () => trpcClient.deploy.environment.list.query({ projectId }), + getKey: (item) => item.id, + id: `${projectId}-environments`, + }), + ); +} diff --git a/apps/dashboard/lib/collections/deploy/projects.ts b/apps/dashboard/lib/collections/deploy/projects.ts new file mode 100644 index 0000000000..ee5a22ec24 --- /dev/null +++ b/apps/dashboard/lib/collections/deploy/projects.ts @@ -0,0 +1,98 @@ +import { queryCollectionOptions } from "@tanstack/query-db-collection"; +import { createCollection } from "@tanstack/react-db"; +import { toast } from "@unkey/ui"; +import { z } from "zod"; +import { queryClient, trpcClient } from "../client"; + +const schema = z.object({ + id: z.string(), + name: z.string(), + slug: z.string(), + gitRepositoryUrl: z.string().nullable(), + updatedAt: z.number().int().nullable(), + liveDeploymentId: z.string().nullable(), +}); + +export const createProjectRequestSchema = z.object({ + name: z.string().trim().min(1, "Project name is required").max(256, "Project name too long"), + slug: z + .string() + .trim() + .min(1, "Project slug is required") + .max(256, "Project slug too long") + .regex( + /^[a-z0-9-]+$/, + "Project slug must contain only lowercase letters, numbers, and hyphens", + ), + gitRepositoryUrl: z.string().trim().url("Must be a valid URL").nullable().or(z.literal("")), +}); + +export type Project = z.infer; +export type CreateProjectRequestSchema = z.infer; + +export const projects = createCollection( + queryCollectionOptions({ + queryClient, + queryKey: ["projects"], + retry: 3, + queryFn: async () => { + return await trpcClient.deploy.project.list.query(); + }, + getKey: (item) => item.id, + onInsert: async ({ transaction }) => { + const { changes } = transaction.mutations[0]; + + const createInput = createProjectRequestSchema.parse({ + name: changes.name, + slug: changes.slug, + gitRepositoryUrl: changes.gitRepositoryUrl, + }); + const mutation = trpcClient.deploy.project.create.mutate(createInput); + + toast.promise(mutation, { + loading: "Creating project...", + success: "Project created successfully", + error: (err) => { + console.error("Failed to create project", err); + + switch (err.data?.code) { + case "CONFLICT": + return { + message: "Project Already Exists", + description: + err.message || "A project with this slug already exists in your workspace.", + }; + case "FORBIDDEN": + return { + message: "Permission Denied", + description: + err.message || "You don't have permission to create projects in this workspace.", + }; + case "BAD_REQUEST": + return { + message: "Invalid Configuration", + description: `Please check your project settings. ${err.message || ""}`, + }; + case "INTERNAL_SERVER_ERROR": + return { + message: "Server Error", + description: + "We encountered an issue while creating your project. Please try again later or contact support at support@unkey.dev", + }; + case "NOT_FOUND": + return { + message: "Project Creation Failed", + description: "Unable to find the workspace. Please refresh and try again.", + }; + default: + return { + message: "Failed to Create Project", + description: err.message || "An unexpected error occurred. Please try again later.", + }; + } + }, + }); + await mutation; + }, + }), +); diff --git a/apps/dashboard/lib/collections/deployments.ts b/apps/dashboard/lib/collections/deployments.ts deleted file mode 100644 index 2ea806253f..0000000000 --- a/apps/dashboard/lib/collections/deployments.ts +++ /dev/null @@ -1,84 +0,0 @@ -"use client"; -import { queryCollectionOptions } from "@tanstack/query-db-collection"; -import { createCollection } from "@tanstack/react-db"; -import { z } from "zod"; -import { queryClient, trpcClient } from "./client"; - -const schema = z.object({ - id: z.string(), - projectId: z.string(), - environmentId: z.string(), - // Git information - gitCommitSha: z.string().nullable(), - gitBranch: z.string().nullable(), - gitCommitMessage: z.string().nullable(), - gitCommitAuthorName: z.string().nullable(), - gitCommitAuthorEmail: z.string().nullable(), - gitCommitAuthorUsername: z.string().nullable(), - gitCommitAuthorAvatarUrl: z.string().nullable(), - gitCommitTimestamp: z.number().int().nullable(), - - // Immutable configuration snapshot - runtimeConfig: z.object({ - regions: z.array( - z.object({ - region: z.string(), - vmCount: z.number().min(1).max(100), - }), - ), - cpus: z.number().min(1).max(16), - memory: z.number().min(1).max(1024), - }), - - // Deployment status - status: z.enum(["pending", "building", "deploying", "network", "ready", "failed"]), - createdAt: z.number(), -}); - -export type Deployment = z.infer; - -export const deployments = createCollection( - queryCollectionOptions({ - queryClient, - queryKey: ["deployments"], - retry: 3, - queryFn: () => trpcClient.deployment.list.query(), - - getKey: (item) => item.id, - onInsert: async () => { - throw new Error("Not implemented"); - // const { changes: newNamespace } = transaction.mutations[0]; - // - // const p = trpcClient.deploy.project.create.mutate(schema.parse({ - // id: "created", // will be replaced by the actual ID after creation - // name: newNamespace.name, - // slug: newNamespace.slug, - // gitRepositoryUrl: newNamespace.gitRepositoryUrl ?? null, - // updatedAt: null, - // })) - // toast.promise(p, { - // loading: "Creating project...", - // success: "Project created", - // error: (res) => { - // console.error("Failed to create project", res); - // return { - // message: "Failed to create project", - // description: res.message, - // }; - // }, - // }); - // await p; - }, - onDelete: async () => { - throw new Error("Not implemented"); - // const { original } = transaction.mutations[0]; - // const p = trpcClient.deploy.project.delete.mutate({ projectId: original.id }); - // toast.promise(p, { - // loading: "Deleting project...", - // success: "Project deleted", - // error: "Failed to delete project", - // }); - // await p; - }, - }), -); diff --git a/apps/dashboard/lib/collections/domains.ts b/apps/dashboard/lib/collections/domains.ts deleted file mode 100644 index 7cc212c41b..0000000000 --- a/apps/dashboard/lib/collections/domains.ts +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; -import { queryCollectionOptions } from "@tanstack/query-db-collection"; -import { createCollection } from "@tanstack/react-db"; -import { z } from "zod"; -import { queryClient, trpcClient } from "./client"; - -const schema = z.object({ - id: z.string(), - domain: z.string(), - type: z.enum(["custom", "wildcard"]), - projectId: z.string().nullable(), -}); - -export type Domain = z.infer; - -export const domains = createCollection( - queryCollectionOptions({ - queryClient, - queryKey: ["domains"], - retry: 3, - queryFn: () => trpcClient.domain.list.query(), - - getKey: (item) => item.id, - onInsert: async () => { - throw new Error("Not implemented"); - // const { changes: newNamespace } = transaction.mutations[0]; - // - // const p = trpcClient.deploy.project.create.mutate(schema.parse({ - // id: "created", // will be replaced by the actual ID after creation - // name: newNamespace.name, - // slug: newNamespace.slug, - // gitRepositoryUrl: newNamespace.gitRepositoryUrl ?? null, - // updatedAt: null, - // })) - // toast.promise(p, { - // loading: "Creating project...", - // success: "Project created", - // error: (res) => { - // console.error("Failed to create project", res); - // return { - // message: "Failed to create project", - // description: res.message, - // }; - // }, - // }); - // await p; - }, - onDelete: async () => { - throw new Error("Not implemented"); - // const { original } = transaction.mutations[0]; - // const p = trpcClient.deploy.project.delete.mutate({ projectId: original.id }); - // toast.promise(p, { - // loading: "Deleting project...", - // success: "Project deleted", - // error: "Failed to delete project", - // }); - // await p; - }, - }), -); diff --git a/apps/dashboard/lib/collections/environments.ts b/apps/dashboard/lib/collections/environments.ts deleted file mode 100644 index ba747e8dd4..0000000000 --- a/apps/dashboard/lib/collections/environments.ts +++ /dev/null @@ -1,77 +0,0 @@ -"use client"; -import { queryCollectionOptions } from "@tanstack/query-db-collection"; -import { createCollection } from "@tanstack/react-db"; -import { z } from "zod"; -import { queryClient, trpcClient } from "./client"; - -const schema = z.object({ - id: z.string(), - projectId: z.string(), - slug: z.string(), -}); - -export type Environment = z.infer; - -export const environments = createCollection( - queryCollectionOptions({ - queryClient, - queryKey: ["environments"], - retry: 3, - queryFn: () => trpcClient.environment.list.query(), - - getKey: (item) => item.id, - onInsert: async () => { - throw new Error("Not implemented"); - // const { changes: newNamespace } = transaction.mutations[0]; - // - // const p = trpcClient.deploy.project.create.mutate(schema.parse({ - // id: "created", // will be replaced by the actual ID after creation - // name: newNamespace.name, - // slug: newNamespace.slug, - // gitRepositoryUrl: newNamespace.gitRepositoryUrl ?? null, - // updatedAt: null, - // })) - // toast.promise(p, { - // loading: "Creating project...", - // success: "Project created", - // error: (res) => { - // console.error("Failed to create project", res); - // return { - // message: "Failed to create project", - // description: res.message, - // }; - // }, - // }); - // await p; - }, - onUpdate: async () => { - throw new Error("Not implemented"); - // const { changes: updatedNamespace } = transaction.mutations[0]; - // - // const p = trpcClient.deploy.project.update.mutate(schema.parse({ - // id: updatedNamespace.id, - // name: updatedNamespace.name, - // slug: updatedNamespace.slug, - // gitRepositoryUrl: updatedNamespace.gitRepositoryUrl ?? null, - // updatedAt: new Date(), - // })); - // toast.promise(p, { - // loading: "Updating project...", - // success: "Project updated", - // error: "Failed to update project", - // }); - // await p; - }, - onDelete: async () => { - throw new Error("Not implemented"); - // const { original } = transaction.mutations[0]; - // const p = trpcClient.deploy.project.delete.mutate({ projectId: original.id }); - // toast.promise(p, { - // loading: "Deleting project...", - // success: "Project deleted", - // error: "Failed to delete project", - // }); - // await p; - }, - }), -); diff --git a/apps/dashboard/lib/collections/index.ts b/apps/dashboard/lib/collections/index.ts index 5fcd6ab171..dd323fa98e 100644 --- a/apps/dashboard/lib/collections/index.ts +++ b/apps/dashboard/lib/collections/index.ts @@ -1,33 +1,85 @@ "use client"; +import { createDeploymentsCollection } from "./deploy/deployments"; +import { createDomainsCollection } from "./deploy/domains"; +import { createEnvironmentsCollection } from "./deploy/environments"; +import { projects } from "./deploy/projects"; +import { ratelimitNamespaces } from "./ratelimit/namespaces"; +import { ratelimitOverrides } from "./ratelimit/overrides"; -import { deployments } from "./deployments"; -import { domains } from "./domains"; -import { environments } from "./environments"; -import { projects } from "./projects"; -import { ratelimitNamespaces } from "./ratelimit_namespaces"; -import { ratelimitOverrides } from "./ratelimit_overrides"; +// Export types +export type { Deployment } from "./deploy/deployments"; +export type { Domain } from "./deploy/domains"; +export type { Project } from "./deploy/projects"; +export type { RatelimitNamespace } from "./ratelimit/namespaces"; +export type { RatelimitOverride } from "./ratelimit/overrides"; +export type { Environment } from "./deploy/environments"; -export type { Deployment } from "./deployments"; -export type { Domain } from "./domains"; -export type { Project } from "./projects"; -export type { RatelimitNamespace } from "./ratelimit_namespaces"; -export type { RatelimitOverride } from "./ratelimit_overrides"; -export type { Environment } from "./environments"; +type ProjectCollections = { + environments: ReturnType; + domains: ReturnType; + deployments: ReturnType; + projects: typeof projects; +}; + +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), + projects, + }); + } + // biome-ignore lint/style/noNonNullAssertion: Its okay + return this.projectCollections.get(projectId)!; + } + + async cleanup(projectId: string) { + const collections = this.projectCollections.get(projectId); + if (collections) { + await Promise.all([ + collections.environments.cleanup(), + collections.domains.cleanup(), + collections.deployments.cleanup(), + // Note: projects is shared, don't clean it up per project + ]); + 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, - projects, - domains, - deployments, - environments, -}; +} as const; -// resets all collections data and preloads new export async function reset() { + await collectionManager.cleanupAll(); + // Preload global collections after cleanup await Promise.all( Object.values(collection).map(async (c) => { - await c.cleanup(); await c.preload(); }), ); diff --git a/apps/dashboard/lib/collections/projects.ts b/apps/dashboard/lib/collections/projects.ts deleted file mode 100644 index 418babe24a..0000000000 --- a/apps/dashboard/lib/collections/projects.ts +++ /dev/null @@ -1,64 +0,0 @@ -"use client"; -import { queryCollectionOptions } from "@tanstack/query-db-collection"; -import { createCollection } from "@tanstack/react-db"; -import { toast } from "@unkey/ui"; -import { z } from "zod"; -import { queryClient, trpcClient } from "./client"; - -const schema = z.object({ - id: z.string(), - name: z.string(), - slug: z.string(), - gitRepositoryUrl: z.string().nullable(), - updatedAt: z.number().int().nullable(), - activeDeploymentId: z.string().nullable(), -}); - -export type Project = z.infer; - -export const projects = createCollection( - queryCollectionOptions({ - queryClient, - queryKey: ["projects"], - retry: 3, - queryFn: async () => { - return await trpcClient.project.list.query(); - }, - getKey: (item) => item.id, - onInsert: async ({ transaction }) => { - const { changes: newNamespace } = transaction.mutations[0]; - - const p = trpcClient.project.create.mutate( - schema.parse({ - id: "created", // will be replaced by the actual ID after creation - name: newNamespace.name, - slug: newNamespace.slug, - gitRepositoryUrl: newNamespace.gitRepositoryUrl ?? null, - updatedAt: null, - }), - ); - toast.promise(p, { - loading: "Creating project...", - success: "Project created", - error: (res) => { - console.error("Failed to create project", res); - return { - message: "Failed to create project", - description: res.message, - }; - }, - }); - await p; - }, - // onDelete: async ({ transaction }) => { - // const { original } = transaction.mutations[0]; - // const p = trpcClient.deploy.project.delete.mutate({ projectId: original.id }); - // toast.promise(p, { - // loading: "Deleting project...", - // success: "Project deleted", - // error: "Failed to delete project", - // }); - // await p; - // }, - }), -); diff --git a/apps/dashboard/lib/collections/ratelimit_namespaces.ts b/apps/dashboard/lib/collections/ratelimit/namespaces.ts similarity index 93% rename from apps/dashboard/lib/collections/ratelimit_namespaces.ts rename to apps/dashboard/lib/collections/ratelimit/namespaces.ts index 15f150095f..ee37bbd9fe 100644 --- a/apps/dashboard/lib/collections/ratelimit_namespaces.ts +++ b/apps/dashboard/lib/collections/ratelimit/namespaces.ts @@ -3,7 +3,7 @@ import { queryCollectionOptions } from "@tanstack/query-db-collection"; import { createCollection } from "@tanstack/react-db"; import { toast } from "@unkey/ui"; import { z } from "zod"; -import { queryClient, trpcClient } from "./client"; +import { queryClient, trpcClient } from "../client"; const schema = z.object({ id: z.string(), @@ -27,7 +27,9 @@ export const ratelimitNamespaces = createCollection( throw new Error("Namespace name is required"); } - const mutation = trpcClient.ratelimit.namespace.create.mutate({ name: newNamespace.name }); + const mutation = trpcClient.ratelimit.namespace.create.mutate({ + name: newNamespace.name, + }); toast.promise(mutation, { loading: "Creating namespace...", success: "Namespace created", @@ -57,7 +59,9 @@ export const ratelimitNamespaces = createCollection( }, onDelete: async ({ transaction }) => { const { original } = transaction.mutations[0]; - const mutation = trpcClient.ratelimit.namespace.delete.mutate({ namespaceId: original.id }); + const mutation = trpcClient.ratelimit.namespace.delete.mutate({ + namespaceId: original.id, + }); toast.promise(mutation, { loading: "Deleting namespace...", success: "Namespace deleted", diff --git a/apps/dashboard/lib/collections/ratelimit_overrides.ts b/apps/dashboard/lib/collections/ratelimit/overrides.ts similarity index 96% rename from apps/dashboard/lib/collections/ratelimit_overrides.ts rename to apps/dashboard/lib/collections/ratelimit/overrides.ts index 47698eca60..3ae65677b8 100644 --- a/apps/dashboard/lib/collections/ratelimit_overrides.ts +++ b/apps/dashboard/lib/collections/ratelimit/overrides.ts @@ -3,7 +3,7 @@ import { queryCollectionOptions } from "@tanstack/query-db-collection"; import { createCollection } from "@tanstack/react-db"; import { toast } from "@unkey/ui"; import { z } from "zod"; -import { queryClient, trpcClient } from "./client"; +import { queryClient, trpcClient } from "../client"; const schema = z.object({ id: z.string(), @@ -56,7 +56,9 @@ export const ratelimitOverrides = createCollection( }, onDelete: async ({ transaction }) => { const { original } = transaction.mutations[0]; - const mutation = trpcClient.ratelimit.override.delete.mutate({ id: original.id }); + const mutation = trpcClient.ratelimit.override.delete.mutate({ + id: original.id, + }); toast.promise(mutation, { loading: "Deleting override...", success: "Override deleted", diff --git a/apps/dashboard/lib/trpc/routers/deployment/buildLogs.ts b/apps/dashboard/lib/trpc/routers/deploy/deployment/buildLogs.ts similarity index 100% rename from apps/dashboard/lib/trpc/routers/deployment/buildLogs.ts rename to apps/dashboard/lib/trpc/routers/deploy/deployment/buildLogs.ts diff --git a/apps/dashboard/lib/trpc/routers/deployment/getById.ts b/apps/dashboard/lib/trpc/routers/deploy/deployment/getById.ts similarity index 95% rename from apps/dashboard/lib/trpc/routers/deployment/getById.ts rename to apps/dashboard/lib/trpc/routers/deploy/deployment/getById.ts index 160a5ff36e..ee7b18bfd1 100644 --- a/apps/dashboard/lib/trpc/routers/deployment/getById.ts +++ b/apps/dashboard/lib/trpc/routers/deploy/deployment/getById.ts @@ -1,7 +1,7 @@ import { db } from "@/lib/db"; +import { requireUser, requireWorkspace, t } from "@/lib/trpc/trpc"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { requireUser, requireWorkspace, t } from "../../trpc"; export const getById = t.procedure .use(requireUser) diff --git a/apps/dashboard/lib/trpc/routers/deployment/getOpenApiDiff.ts b/apps/dashboard/lib/trpc/routers/deploy/deployment/getOpenApiDiff.ts similarity index 98% rename from apps/dashboard/lib/trpc/routers/deployment/getOpenApiDiff.ts rename to apps/dashboard/lib/trpc/routers/deploy/deployment/getOpenApiDiff.ts index 1ae22b8344..d8986fb26b 100644 --- a/apps/dashboard/lib/trpc/routers/deployment/getOpenApiDiff.ts +++ b/apps/dashboard/lib/trpc/routers/deploy/deployment/getOpenApiDiff.ts @@ -1,8 +1,8 @@ // trpc/routers/deployments/getOpenApiDiff.ts import { db } from "@/lib/db"; +import { requireUser, requireWorkspace, t } from "@/lib/trpc/trpc"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { requireUser, requireWorkspace, t } from "../../trpc"; export const getOpenApiDiff = t.procedure .use(requireUser) diff --git a/apps/dashboard/lib/trpc/routers/deployment/index.ts b/apps/dashboard/lib/trpc/routers/deploy/deployment/index.ts similarity index 55% rename from apps/dashboard/lib/trpc/routers/deployment/index.ts rename to apps/dashboard/lib/trpc/routers/deploy/deployment/index.ts index 71cdaf65eb..d38d8251d0 100644 --- a/apps/dashboard/lib/trpc/routers/deployment/index.ts +++ b/apps/dashboard/lib/trpc/routers/deploy/deployment/index.ts @@ -1,14 +1,10 @@ -import { t } from "../../trpc"; +import { t } from "@/lib/trpc/trpc"; import { getById } from "./getById"; import { getOpenApiDiff } from "./getOpenApiDiff"; import { listDeployments } from "./list"; -import { listByEnvironment } from "./listByEnvironment"; -import { listByProject } from "./listByProject"; export const deploymentRouter = t.router({ list: listDeployments, - listByEnvironment: listByEnvironment, - listByProject: listByProject, getById: getById, getOpenApiDiff: getOpenApiDiff, }); diff --git a/apps/dashboard/lib/trpc/routers/deploy/deployment/list.ts b/apps/dashboard/lib/trpc/routers/deploy/deployment/list.ts new file mode 100644 index 0000000000..12ffe8657e --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/deploy/deployment/list.ts @@ -0,0 +1,54 @@ +import { db } from "@/lib/db"; +import { requireUser, requireWorkspace, t } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +export const listDeployments = t.procedure + .use(requireUser) + .use(requireWorkspace) + .input(z.object({ projectId: z.string() })) + .query(async ({ ctx, input }) => { + try { + // Get all deployments for this workspace and specific project + const deployments = await db.query.deployments.findMany({ + where: (table, { eq, and }) => + and(eq(table.workspaceId, ctx.workspace.id), eq(table.projectId, input.projectId)), + columns: { + id: true, + projectId: true, + environmentId: true, + gitCommitSha: true, + gitBranch: true, + gitCommitMessage: true, + gitCommitAuthorName: true, + gitCommitAuthorEmail: true, + gitCommitAuthorUsername: true, + gitCommitAuthorAvatarUrl: true, + gitCommitTimestamp: true, + runtimeConfig: true, + status: true, + createdAt: true, + }, + limit: 500, + }); + + return deployments.map((deployment) => ({ + ...deployment, + // Replace NULL git fields with dummy data that clearly indicates it's fake + gitCommitSha: deployment.gitCommitSha ?? "abc123ef456789012345678901234567890abcdef", + gitBranch: deployment.gitBranch ?? "main", + gitCommitMessage: deployment.gitCommitMessage ?? "[DUMMY] Initial commit", + gitCommitAuthorName: deployment.gitCommitAuthorName ?? "[DUMMY] Unknown Author", + gitCommitAuthorEmail: deployment.gitCommitAuthorEmail ?? "dummy@example.com", + gitCommitAuthorUsername: deployment.gitCommitAuthorUsername ?? "dummy-user", + gitCommitAuthorAvatarUrl: + deployment.gitCommitAuthorAvatarUrl ?? "https://github.com/identicons/dummy-user.png", + gitCommitTimestamp: deployment.gitCommitTimestamp ?? Date.now() - 86400000, + })); + } catch (_error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to fetch deployments", + }); + } + }); diff --git a/apps/dashboard/lib/trpc/routers/deployment/llm-search/index.ts b/apps/dashboard/lib/trpc/routers/deploy/deployment/llm-search/index.ts similarity index 100% rename from apps/dashboard/lib/trpc/routers/deployment/llm-search/index.ts rename to apps/dashboard/lib/trpc/routers/deploy/deployment/llm-search/index.ts diff --git a/apps/dashboard/lib/trpc/routers/deployment/llm-search/utils.ts b/apps/dashboard/lib/trpc/routers/deploy/deployment/llm-search/utils.ts similarity index 100% rename from apps/dashboard/lib/trpc/routers/deployment/llm-search/utils.ts rename to apps/dashboard/lib/trpc/routers/deploy/deployment/llm-search/utils.ts diff --git a/apps/dashboard/lib/trpc/routers/rollback.ts b/apps/dashboard/lib/trpc/routers/deploy/deployment/rollback.ts similarity index 100% rename from apps/dashboard/lib/trpc/routers/rollback.ts rename to apps/dashboard/lib/trpc/routers/deploy/deployment/rollback.ts diff --git a/apps/dashboard/lib/trpc/routers/domains/list.ts b/apps/dashboard/lib/trpc/routers/deploy/domains/list.ts similarity index 76% rename from apps/dashboard/lib/trpc/routers/domains/list.ts rename to apps/dashboard/lib/trpc/routers/deploy/domains/list.ts index 7a280745af..903acb08e0 100644 --- a/apps/dashboard/lib/trpc/routers/domains/list.ts +++ b/apps/dashboard/lib/trpc/routers/deploy/domains/list.ts @@ -1,15 +1,18 @@ import { db } from "@/lib/db"; import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; import { TRPCError } from "@trpc/server"; +import { z } from "zod"; export const listDomains = t.procedure .use(requireUser) .use(requireWorkspace) .use(withRatelimit(ratelimit.read)) - .query(async ({ ctx }) => { + .input(z.object({ projectId: z.string() })) + .query(async ({ ctx, input }) => { return await db.query.domains .findMany({ - where: (table, { eq }) => eq(table.workspaceId, ctx.workspace.id), + where: (table, { eq, and }) => + and(eq(table.workspaceId, ctx.workspace.id), eq(table.projectId, input.projectId)), columns: { id: true, domain: true, diff --git a/apps/dashboard/lib/trpc/routers/project/envs/list.ts b/apps/dashboard/lib/trpc/routers/deploy/envs/list.ts similarity index 100% rename from apps/dashboard/lib/trpc/routers/project/envs/list.ts rename to apps/dashboard/lib/trpc/routers/deploy/envs/list.ts diff --git a/apps/dashboard/lib/trpc/routers/project/create.ts b/apps/dashboard/lib/trpc/routers/deploy/project/create.ts similarity index 95% rename from apps/dashboard/lib/trpc/routers/project/create.ts rename to apps/dashboard/lib/trpc/routers/deploy/project/create.ts index 5a9050a872..f30c7d2f48 100644 --- a/apps/dashboard/lib/trpc/routers/project/create.ts +++ b/apps/dashboard/lib/trpc/routers/deploy/project/create.ts @@ -1,5 +1,5 @@ -import { createProjectSchema } from "@/app/(app)/projects/_components/create-project/create-project.schema"; import { insertAuditLogs } from "@/lib/audit"; +import { createProjectRequestSchema } from "@/lib/collections/deploy/projects"; import { db, schema } from "@/lib/db"; import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; import { TRPCError } from "@trpc/server"; @@ -8,7 +8,7 @@ import { newId } from "@unkey/id"; export const createProject = t.procedure .use(requireUser) .use(requireWorkspace) - .input(createProjectSchema) + .input(createProjectRequestSchema) .use(withRatelimit(ratelimit.create)) .mutation(async ({ ctx, input }) => { const userId = ctx.user.id; @@ -70,7 +70,7 @@ export const createProject = t.procedure workspaceId, name: input.name, slug: input.slug, - activeDeploymentId: null, + liveDeploymentId: null, gitRepositoryUrl: input.gitRepositoryUrl || null, defaultBranch: "main", deleteProtection: false, @@ -105,8 +105,11 @@ export const createProject = t.procedure id: environmentId, workspaceId, projectId, - slug: slug, + createdAt: now, + updatedAt: now, + slug, }); + await insertAuditLogs(tx, { workspaceId, actor: { diff --git a/apps/dashboard/lib/trpc/routers/project/list.ts b/apps/dashboard/lib/trpc/routers/deploy/project/list.ts similarity index 96% rename from apps/dashboard/lib/trpc/routers/project/list.ts rename to apps/dashboard/lib/trpc/routers/deploy/project/list.ts index 71351fc4b8..5c279b5acd 100644 --- a/apps/dashboard/lib/trpc/routers/project/list.ts +++ b/apps/dashboard/lib/trpc/routers/deploy/project/list.ts @@ -16,7 +16,7 @@ export const listProjects = t.procedure slug: true, updatedAt: true, gitRepositoryUrl: true, - activeDeploymentId: true, + liveDeploymentId: true, }, }) .catch((error) => { diff --git a/apps/dashboard/lib/trpc/routers/deployment/list.ts b/apps/dashboard/lib/trpc/routers/deployment/list.ts deleted file mode 100644 index 499d5f2f1b..0000000000 --- a/apps/dashboard/lib/trpc/routers/deployment/list.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { db } from "@/lib/db"; -import { TRPCError } from "@trpc/server"; -import { requireUser, requireWorkspace, t } from "../../trpc"; - -export const listDeployments = t.procedure - .use(requireUser) - .use(requireWorkspace) - .query(async ({ ctx }) => { - try { - // Get all deployments for this workspace with project info - return await db.query.deployments.findMany({ - where: (table, { eq }) => eq(table.workspaceId, ctx.workspace.id), - columns: { - id: true, - projectId: true, - environmentId: true, - gitCommitSha: true, - gitBranch: true, - gitCommitMessage: true, - gitCommitAuthorName: true, - gitCommitAuthorEmail: true, - gitCommitAuthorUsername: true, - gitCommitAuthorAvatarUrl: true, - gitCommitTimestamp: true, - runtimeConfig: true, - status: true, - createdAt: true, - }, - limit: 500, - }); - } catch (_error) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to fetch deployments", - }); - } - }); diff --git a/apps/dashboard/lib/trpc/routers/deployment/listByEnvironment.ts b/apps/dashboard/lib/trpc/routers/deployment/listByEnvironment.ts deleted file mode 100644 index 863d035e78..0000000000 --- a/apps/dashboard/lib/trpc/routers/deployment/listByEnvironment.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { db } from "@/lib/db"; -import { TRPCError } from "@trpc/server"; -import { z } from "zod"; -import { requireUser, requireWorkspace, t } from "../../trpc"; - -export const listByEnvironment = t.procedure - .use(requireUser) - .use(requireWorkspace) - .input( - z.object({ - projectId: z.string(), - environmentId: z.string(), - }), - ) - .query(async ({ input, ctx }) => { - try { - // First verify the project exists and belongs to this workspace - const project = await db.query.projects.findFirst({ - where: (table, { eq, and }) => - and(eq(table.id, input.projectId), eq(table.workspaceId, ctx.workspace.id)), - }); - - if (!project) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Project not found", - }); - } - - // Get all deployments for this project and environment - const deployments = await db.query.deployments.findMany({ - where: (table, { eq, and }) => - and(eq(table.projectId, input.projectId), eq(table.environmentId, input.environmentId)), - orderBy: (table, { desc }) => [desc(table.createdAt)], - }); - - return { - deployments: deployments.map((deployment) => ({ - id: deployment.id, - status: deployment.status, - gitCommitSha: deployment.gitCommitSha, - gitBranch: deployment.gitBranch, - createdAt: deployment.createdAt, - updatedAt: deployment.updatedAt, - })), - }; - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to fetch deployments for environment", - }); - } - }); diff --git a/apps/dashboard/lib/trpc/routers/deployment/listByProject.ts b/apps/dashboard/lib/trpc/routers/deployment/listByProject.ts deleted file mode 100644 index 6cabf2a857..0000000000 --- a/apps/dashboard/lib/trpc/routers/deployment/listByProject.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { db } from "@/lib/db"; -import { TRPCError } from "@trpc/server"; -import { z } from "zod"; -import { requireUser, requireWorkspace, t } from "../../trpc"; - -export const listByProject = t.procedure - .use(requireUser) - .use(requireWorkspace) - .input( - z.object({ - projectId: z.string(), - }), - ) - .query(async ({ input, ctx }) => { - try { - // First verify the project exists and belongs to this workspace - const project = await db.query.projects.findFirst({ - where: (table, { eq, and }) => - and(eq(table.id, input.projectId), eq(table.workspaceId, ctx.workspace.id)), - }); - - if (!project) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Project not found", - }); - } - - // Get all deployments for this project - const deployments = await db.query.deployments.findMany({ - where: (table, { eq }) => eq(table.projectId, input.projectId), - orderBy: (table, { desc }) => [desc(table.createdAt)], - with: { - environment: { columns: { slug: true } }, - project: { columns: { id: true, name: true, slug: true } }, - }, - }); - - return { - project: { - id: project.id, - name: project.name, - slug: project.slug, - gitRepositoryUrl: project.gitRepositoryUrl, - createdAt: project.createdAt, - updatedAt: project.updatedAt, - }, - deployments: deployments.map((deployment) => ({ - id: deployment.id, - status: deployment.status, - gitCommitSha: deployment.gitCommitSha, - gitBranch: deployment.gitBranch, - environment: deployment.environment.slug, - createdAt: deployment.createdAt, - updatedAt: deployment.updatedAt, - project: deployment.project - ? { - id: deployment.project.id, - name: deployment.project.name, - slug: deployment.project.slug, - } - : null, - })), - }; - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to fetch deployments for project", - }); - } - }); diff --git a/apps/dashboard/lib/trpc/routers/environment/list.ts b/apps/dashboard/lib/trpc/routers/environment/list.ts index b2bdab433a..45325417b3 100644 --- a/apps/dashboard/lib/trpc/routers/environment/list.ts +++ b/apps/dashboard/lib/trpc/routers/environment/list.ts @@ -1,21 +1,35 @@ -import { db } from "@/lib/db"; +import { and, db, eq } from "@/lib/db"; import { TRPCError } from "@trpc/server"; +import { environments } from "@unkey/db/src/schema"; +import { z } from "zod"; import { requireUser, requireWorkspace, t } from "../../trpc"; export const listEnvironments = t.procedure .use(requireUser) .use(requireWorkspace) - .query(async ({ ctx }) => { + .input( + z.object({ + projectId: z.string(), + }), + ) + .query(async ({ ctx, input }) => { try { return await db.query.environments.findMany({ - where: (table, { eq }) => eq(table.workspaceId, ctx.workspace.id), + where: and( + eq(environments.workspaceId, ctx.workspace.id), + eq(environments.projectId, input.projectId), + ), columns: { id: true, projectId: true, slug: true, }, }); - } catch (_error) { + } catch (error) { + if (error instanceof TRPCError) { + throw error; + } + console.error("Failed to fetch environments:", error); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to fetch environments", diff --git a/apps/dashboard/lib/trpc/routers/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index 0724773e7b..2130b4254b 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -37,11 +37,15 @@ import { searchRolesPermissions } from "./authorization/roles/permissions/search import { queryRoles } from "./authorization/roles/query"; import { upsertRole } from "./authorization/roles/upsert"; import { queryUsage } from "./billing/query-usage"; -import { getDeploymentBuildLogs } from "./deployment/buildLogs"; -import { getOpenApiDiff } from "./deployment/getOpenApiDiff"; -import { listDeployments } from "./deployment/list"; -import { searchDeployments } from "./deployment/llm-search"; -import { listDomains } from "./domains/list"; +import { getDeploymentBuildLogs } from "./deploy/deployment/buildLogs"; +import { getOpenApiDiff } from "./deploy/deployment/getOpenApiDiff"; +import { listDeployments } from "./deploy/deployment/list"; +import { searchDeployments } from "./deploy/deployment/llm-search"; +import { rollback } from "./deploy/deployment/rollback"; +import { listDomains } from "./deploy/domains/list"; +import { getEnvs } from "./deploy/envs/list"; +import { createProject } from "./deploy/project/create"; +import { listProjects } from "./deploy/project/list"; import { listEnvironments } from "./environment/list"; import { createIdentity } from "./identity/create"; import { queryIdentities } from "./identity/query"; @@ -80,9 +84,6 @@ import { updateMembership, } from "./org"; import { createPlainIssue } from "./plain"; -import { createProject } from "./project/create"; -import { getEnvs } from "./project/envs/list"; -import { listProjects } from "./project/list"; import { createNamespace } from "./ratelimit/createNamespace"; import { createOverride } from "./ratelimit/createOverride"; import { deleteNamespace } from "./ratelimit/deleteNamespace"; @@ -107,7 +108,6 @@ import { disconnectPermissionFromRole } from "./rbac/disconnectPermissionFromRol import { disconnectRoleFromKey } from "./rbac/disconnectRoleFromKey"; import { updatePermission } from "./rbac/updatePermission"; import { updateRole } from "./rbac/updateRole"; -import { rollback } from "./rollback"; import { deleteRootKeys } from "./settings/root-keys/delete"; import { rootKeysLlmSearch } from "./settings/root-keys/llm-search"; import { queryRootKeys } from "./settings/root-keys/query"; @@ -312,27 +312,25 @@ export const router = t.router({ query: queryIdentities, search: searchIdentities, }), - project: t.router({ - list: listProjects, - create: createProject, - }), - domain: t.router({ - list: listDomains, - }), - deployment: t.router({ - list: listDeployments, - search: searchDeployments, - getOpenApiDiff: getOpenApiDiff, - buildLogs: getDeploymentBuildLogs, - }), - environment: t.router({ - list: listEnvironments, - }), - environmentVariables: t.router({ - list: getEnvs, - }), deploy: t.router({ - rollback: rollback, + project: t.router({ + list: listProjects, + create: createProject, + }), + environment: t.router({ + list_dummy: getEnvs, + list: listEnvironments, + }), + domain: t.router({ + list: listDomains, + }), + deployment: t.router({ + list: listDeployments, + search: searchDeployments, + getOpenApiDiff: getOpenApiDiff, + buildLogs: getDeploymentBuildLogs, + rollback, + }), }), }); diff --git a/go/apps/ctrl/services/deployment/deploy_workflow.go b/go/apps/ctrl/services/deployment/deploy_workflow.go index 864deb36f8..5a9a8e721a 100644 --- a/go/apps/ctrl/services/deployment/deploy_workflow.go +++ b/go/apps/ctrl/services/deployment/deploy_workflow.go @@ -281,7 +281,6 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro // Create database entries for all domains err = hydra.StepVoid(ctx, "create-domain-entries", func(stepCtx context.Context) error { - // Prepare bulk insert parameters domainParams := make([]db.InsertDomainParams, 0, len(allDomains)) currentTime := time.Now().UnixMilli() @@ -382,6 +381,16 @@ func (w *DeployWorkflow) Run(ctx hydra.WorkflowContext, req *DeployRequest) erro } w.logger.Info("deployment status updated to ready", "deployment_id", req.DeploymentID) + // TODO: This section will be removed in the future in favor of "Promote to Production" + err = db.Query.UpdateProjectLiveDeploymentId(stepCtx, w.db.RW(), db.UpdateProjectLiveDeploymentIdParams{ + ID: req.ProjectID, + LiveDeploymentID: sql.NullString{Valid: true, String: req.DeploymentID}, + UpdatedAt: sql.NullInt64{Valid: true, Int64: time.Now().UnixMilli()}, + }) + if err != nil { + return nil, fmt.Errorf("failed to update project %s active deployment ID to %s: %w", req.ProjectID, req.DeploymentID, err) + } + return &DeploymentResult{ DeploymentID: req.DeploymentID, Status: "ready", diff --git a/go/apps/ctrl/services/routing/service.go b/go/apps/ctrl/services/routing/service.go index c66cd159a6..2fe351012c 100644 --- a/go/apps/ctrl/services/routing/service.go +++ b/go/apps/ctrl/services/routing/service.go @@ -358,10 +358,10 @@ func (s *Service) Rollback(ctx context.Context, req *connect.Request[ctrlv1.Roll slog.String("new_deployment_id", targetDeploymentID), ) - err = db.Query.UpdateProjectActiveDeploymentId(ctx, s.db.RW(), db.UpdateProjectActiveDeploymentIdParams{ - ID: deployment.ProjectID, - ActiveDeploymentID: sql.NullString{Valid: true, String: targetDeploymentID}, - UpdatedAt: sql.NullInt64{Valid: true, Int64: time.Now().UnixMilli()}, + err = db.Query.UpdateProjectLiveDeploymentId(ctx, s.db.RW(), db.UpdateProjectLiveDeploymentIdParams{ + ID: deployment.ProjectID, + LiveDeploymentID: sql.NullString{Valid: true, String: targetDeploymentID}, + UpdatedAt: sql.NullInt64{Valid: true, Int64: time.Now().UnixMilli()}, }) if err != nil { s.logger.ErrorContext(ctx, "failed to update project active deployment ID", diff --git a/go/pkg/db/models_generated.go b/go/pkg/db/models_generated.go index dd048fd82d..832653dbd4 100644 --- a/go/pkg/db/models_generated.go +++ b/go/pkg/db/models_generated.go @@ -669,16 +669,16 @@ type Permission struct { } type Project struct { - ID string `db:"id"` - WorkspaceID string `db:"workspace_id"` - Name string `db:"name"` - Slug string `db:"slug"` - GitRepositoryUrl sql.NullString `db:"git_repository_url"` - ActiveDeploymentID sql.NullString `db:"active_deployment_id"` - DefaultBranch sql.NullString `db:"default_branch"` - DeleteProtection sql.NullBool `db:"delete_protection"` - CreatedAt int64 `db:"created_at"` - UpdatedAt sql.NullInt64 `db:"updated_at"` + ID string `db:"id"` + WorkspaceID string `db:"workspace_id"` + Name string `db:"name"` + Slug string `db:"slug"` + GitRepositoryUrl sql.NullString `db:"git_repository_url"` + LiveDeploymentID sql.NullString `db:"live_deployment_id"` + DefaultBranch sql.NullString `db:"default_branch"` + DeleteProtection sql.NullBool `db:"delete_protection"` + CreatedAt int64 `db:"created_at"` + UpdatedAt sql.NullInt64 `db:"updated_at"` } type Quotum struct { diff --git a/go/pkg/db/project_update_active_deployment_id.sql_generated.go b/go/pkg/db/project_update_active_deployment_id.sql_generated.go deleted file mode 100644 index 4b54642eec..0000000000 --- a/go/pkg/db/project_update_active_deployment_id.sql_generated.go +++ /dev/null @@ -1,33 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.29.0 -// source: project_update_active_deployment_id.sql - -package db - -import ( - "context" - "database/sql" -) - -const updateProjectActiveDeploymentId = `-- name: UpdateProjectActiveDeploymentId :exec -UPDATE projects -SET active_deployment_id = ?, updated_at = ? -WHERE id = ? -` - -type UpdateProjectActiveDeploymentIdParams struct { - ActiveDeploymentID sql.NullString `db:"active_deployment_id"` - UpdatedAt sql.NullInt64 `db:"updated_at"` - ID string `db:"id"` -} - -// UpdateProjectActiveDeploymentId -// -// UPDATE projects -// SET active_deployment_id = ?, updated_at = ? -// WHERE id = ? -func (q *Queries) UpdateProjectActiveDeploymentId(ctx context.Context, db DBTX, arg UpdateProjectActiveDeploymentIdParams) error { - _, err := db.ExecContext(ctx, updateProjectActiveDeploymentId, arg.ActiveDeploymentID, arg.UpdatedAt, arg.ID) - return err -} diff --git a/go/pkg/db/project_update_live_deployment_id.sql_generated.go b/go/pkg/db/project_update_live_deployment_id.sql_generated.go new file mode 100644 index 0000000000..e68c491f75 --- /dev/null +++ b/go/pkg/db/project_update_live_deployment_id.sql_generated.go @@ -0,0 +1,33 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: project_update_live_deployment_id.sql + +package db + +import ( + "context" + "database/sql" +) + +const updateProjectLiveDeploymentId = `-- name: UpdateProjectLiveDeploymentId :exec +UPDATE projects +SET live_deployment_id = ?, updated_at = ? +WHERE id = ? +` + +type UpdateProjectLiveDeploymentIdParams struct { + LiveDeploymentID sql.NullString `db:"live_deployment_id"` + UpdatedAt sql.NullInt64 `db:"updated_at"` + ID string `db:"id"` +} + +// UpdateProjectLiveDeploymentId +// +// UPDATE projects +// SET live_deployment_id = ?, updated_at = ? +// WHERE id = ? +func (q *Queries) UpdateProjectLiveDeploymentId(ctx context.Context, db DBTX, arg UpdateProjectLiveDeploymentIdParams) error { + _, err := db.ExecContext(ctx, updateProjectLiveDeploymentId, arg.LiveDeploymentID, arg.UpdatedAt, arg.ID) + return err +} diff --git a/go/pkg/db/querier_generated.go b/go/pkg/db/querier_generated.go index 107c9b5618..3ba37dc3e4 100644 --- a/go/pkg/db/querier_generated.go +++ b/go/pkg/db/querier_generated.go @@ -1674,12 +1674,12 @@ type Querier interface { // // UPDATE `key_auth` SET store_encrypted_keys = ? WHERE id = ? UpdateKeyringKeyEncryption(ctx context.Context, db DBTX, arg UpdateKeyringKeyEncryptionParams) error - //UpdateProjectActiveDeploymentId + //UpdateProjectLiveDeploymentId // // UPDATE projects - // SET active_deployment_id = ?, updated_at = ? + // SET live_deployment_id = ?, updated_at = ? // WHERE id = ? - UpdateProjectActiveDeploymentId(ctx context.Context, db DBTX, arg UpdateProjectActiveDeploymentIdParams) error + UpdateProjectLiveDeploymentId(ctx context.Context, db DBTX, arg UpdateProjectLiveDeploymentIdParams) error //UpdateRatelimit // // UPDATE `ratelimits` diff --git a/go/pkg/db/queries/project_update_active_deployment_id.sql b/go/pkg/db/queries/project_update_active_deployment_id.sql deleted file mode 100644 index e5e7044c91..0000000000 --- a/go/pkg/db/queries/project_update_active_deployment_id.sql +++ /dev/null @@ -1,4 +0,0 @@ --- name: UpdateProjectActiveDeploymentId :exec -UPDATE projects -SET active_deployment_id = ?, updated_at = ? -WHERE id = ?; diff --git a/go/pkg/db/queries/project_update_live_deployment_id.sql b/go/pkg/db/queries/project_update_live_deployment_id.sql new file mode 100644 index 0000000000..45d8c52dc3 --- /dev/null +++ b/go/pkg/db/queries/project_update_live_deployment_id.sql @@ -0,0 +1,4 @@ +-- name: UpdateProjectLiveDeploymentId :exec +UPDATE projects +SET live_deployment_id = ?, updated_at = ? +WHERE id = ?; diff --git a/go/pkg/db/schema.sql b/go/pkg/db/schema.sql index ab014422a7..cb7efc4868 100644 --- a/go/pkg/db/schema.sql +++ b/go/pkg/db/schema.sql @@ -304,7 +304,7 @@ CREATE TABLE `environments` ( `created_at` bigint NOT NULL, `updated_at` bigint, CONSTRAINT `environments_id` PRIMARY KEY(`id`), - CONSTRAINT `environments_workspace_id_slug_idx` UNIQUE(`workspace_id`,`slug`) + CONSTRAINT `environments_project_id_slug_idx` UNIQUE(`project_id`,`slug`) ); CREATE TABLE `projects` ( @@ -313,7 +313,7 @@ CREATE TABLE `projects` ( `name` varchar(256) NOT NULL, `slug` varchar(256) NOT NULL, `git_repository_url` varchar(500), - `active_deployment_id` varchar(256), + `live_deployment_id` varchar(256), `default_branch` varchar(256) DEFAULT 'main', `delete_protection` boolean DEFAULT false, `created_at` bigint NOT NULL, @@ -415,4 +415,3 @@ CREATE INDEX `workspace_idx` ON `domains` (`workspace_id`); CREATE INDEX `project_idx` ON `domains` (`project_id`); CREATE INDEX `workspace_idx` ON `acme_challenges` (`workspace_id`); CREATE INDEX `status_idx` ON `acme_challenges` (`status`); - diff --git a/internal/db/src/schema/environments.ts b/internal/db/src/schema/environments.ts index 8a5766be05..e109613f1c 100644 --- a/internal/db/src/schema/environments.ts +++ b/internal/db/src/schema/environments.ts @@ -19,7 +19,7 @@ export const environments = mysqlTable( ...lifecycleDates, }, (table) => ({ - uniqueSlug: uniqueIndex("environments_workspace_id_slug_idx").on(table.workspaceId, table.slug), + uniqueSlug: uniqueIndex("environments_project_id_slug_idx").on(table.projectId, table.slug), }), ); diff --git a/internal/db/src/schema/projects.ts b/internal/db/src/schema/projects.ts index b9551e5c04..a55fac12d0 100644 --- a/internal/db/src/schema/projects.ts +++ b/internal/db/src/schema/projects.ts @@ -18,7 +18,7 @@ export const projects = mysqlTable( gitRepositoryUrl: varchar("git_repository_url", { length: 500 }), // this is likely temporary but we need a way to point to the current prod deployment. // in the future I think we want to have a special deployment per environment, but for now this is fine - activeDeploymentId: varchar("active_deployment_id", { length: 256 }), + liveDeploymentId: varchar("live_deployment_id", { length: 256 }), defaultBranch: varchar("default_branch", { length: 256 }).default("main"), ...deleteProtection,