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 72966c72bb..7ebaec0d38 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 @@ -2,13 +2,27 @@ import { DatetimePopover } from "@/components/logs/datetime/datetime-popover"; import { cn } from "@/lib/utils"; import { Calendar } from "@unkey/icons"; import { Button } from "@unkey/ui"; -import { useState } from "react"; +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("Last 12 hours"); + const [title, setTitle] = useState(TITLE_EMPTY_DEFAULT); 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; + } + } + setTitle(TITLE_EMPTY_DEFAULT); + }, [filters]); + const timeValues = filters .filter((f) => ["startTime", "endTime", "since"].includes(f.field)) .reduce( diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-filters/components/deployment-status-filter.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-filters/components/deployment-status-filter.tsx index 609c6f15cc..ab1b67d786 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-filters/components/deployment-status-filter.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-filters/components/deployment-status-filter.tsx @@ -7,7 +7,6 @@ type StatusOption = { id: number; status: GroupedDeploymentStatus; display: string; - checked: boolean; }; const baseOptions: StatusOption[] = [ @@ -15,25 +14,21 @@ const baseOptions: StatusOption[] = [ id: 1, status: "pending", display: "Pending", - checked: false, }, { id: 2, - status: "building", - display: "Building", - checked: false, + status: "deploying", + display: "Deploying", }, { id: 3, - status: "completed", + status: "ready", display: "Ready", - checked: false, }, { id: 4, status: "failed", display: "Failed", - checked: false, }, ]; 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 26cd54fe97..145284e0b9 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,31 +1,27 @@ 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"; -type EnvironmentOption = { - id: number; - environment: string; - checked: boolean; -}; - -const options: EnvironmentOption[] = [ - { id: 1, environment: "production", checked: false }, - { id: 2, environment: "preview", checked: false }, -] as const; - export const EnvironmentFilter = () => { const { filters, updateFilters } = useFilters(); + const environments = useLiveQuery((q) => q.from({ environment: collection.environments })); + return ( ({ + id: i, + slug: environment.slug, + }))} filterField="environment" - checkPath="environment" + checkPath="slug" selectionMode="single" renderOptionContent={(checkbox) => ( -
{checkbox.environment}
+
{checkbox.slug}
)} createFilterValue={(option) => ({ - value: option.environment, + value: option.slug, })} filters={filters} updateFilters={updateFilters} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-search/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-search/index.tsx index f4e8f0883b..0ad382118a 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-search/index.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-search/index.tsx @@ -6,7 +6,7 @@ import { useFilters } from "../../../../hooks/use-filters"; export const DeploymentListSearch = () => { const { filters, updateFilters } = useFilters(); - const queryLLMForStructuredOutput = trpc.deploy.project.deployment.search.useMutation({ + const queryLLMForStructuredOutput = trpc.deployment.search.useMutation({ onSuccess(data) { if (data?.filters.length === 0 || !data) { toast.error( diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/actions/deployment-list-table-action.popover.constants.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/actions/deployment-list-table-action.popover.constants.tsx index 3287009cf5..06973e3215 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/actions/deployment-list-table-action.popover.constants.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/actions/deployment-list-table-action.popover.constants.tsx @@ -1,6 +1,6 @@ "use client"; import { type MenuItem, TableActionPopover } from "@/components/logs/table-action.popover"; -import type { Deployment } from "@/lib/trpc/routers/deploy/project/deployment/list"; +import type { Deployment } from "@/lib/collections"; import { PenWriting3 } from "@unkey/icons"; import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"; import { useRouter } from "next/navigation"; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/deployment-status-badge.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/deployment-status-badge.tsx index acf652aeaf..f4dad40bb3 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/deployment-status-badge.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/deployment-status-badge.tsx @@ -2,11 +2,8 @@ import { ArrowDotAntiClockwise, CircleCheck, - CircleDotted, CircleHalfDottedClock, CircleWarning, - Cloud, - CloudUp, HalfDottedCirclePlay, Nut, } from "@unkey/icons"; @@ -32,15 +29,7 @@ const statusConfigs: Record = { textColor: "text-grayA-11", iconColor: "text-gray-11", }, - downloading_docker_image: { - icon: Cloud, - label: "Downloading", - bgColor: "bg-gradient-to-r from-infoA-5 to-transparent", - textColor: "text-infoA-11", - iconColor: "text-info-11", - animated: true, - }, - building_rootfs: { + building: { icon: Nut, label: "Building", bgColor: "bg-gradient-to-r from-infoA-5 to-transparent", @@ -48,31 +37,15 @@ const statusConfigs: Record = { iconColor: "text-info-11", animated: true, }, - uploading_rootfs: { - icon: CloudUp, - label: "Uploading", - bgColor: "bg-gradient-to-r from-infoA-5 to-transparent", - textColor: "text-infoA-11", - iconColor: "text-info-11", - animated: true, - }, - creating_vm: { - icon: CircleDotted, - label: "Creating VM", - bgColor: "bg-gradient-to-r from-infoA-5 to-transparent", - textColor: "text-infoA-11", - iconColor: "text-info-11", - animated: true, - }, - booting_vm: { + deploying: { icon: HalfDottedCirclePlay, - label: "Booting", + label: "Deploying", bgColor: "bg-gradient-to-r from-infoA-5 to-transparent", textColor: "text-infoA-11", iconColor: "text-info-11", animated: true, }, - assigning_domains: { + network: { icon: ArrowDotAntiClockwise, label: "Assigning Domains", bgColor: "bg-gradient-to-r from-infoA-5 to-transparent", @@ -80,7 +53,7 @@ const statusConfigs: Record = { iconColor: "text-info-11", animated: true, }, - completed: { + ready: { icon: CircleCheck, label: "Ready", bgColor: "bg-successA-3", 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 aa2156a5d3..e6d516d950 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,13 +2,17 @@ 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 { shortenId } from "@/lib/shorten-id"; -import type { Deployment } from "@/lib/trpc/routers/deploy/project/deployment/list"; +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 { DeploymentStatusBadge } from "./components/deployment-status-badge"; import { EnvStatusBadge } from "./components/env-status-badge"; import { @@ -22,7 +26,6 @@ import { SourceColumnSkeleton, StatusColumnSkeleton, } from "./components/skeletons"; -import { useDeploymentsListQuery } from "./hooks/use-deployments-list-query"; import { getRowClassName } from "./utils/get-row-class"; const DeploymentListTableActions = dynamic( @@ -38,21 +41,114 @@ const DeploymentListTableActions = dynamic( const COMPACT_BREAKPOINT = 1200; -export const DeploymentsList = () => { - const { deployments, isLoading, isLoadingMore, loadMore, totalCount, hasMore } = - useDeploymentsListQuery(); - const [selectedDeployment, setSelectedDeployment] = useState(null); +type Props = { + projectId: string; +}; + +export const DeploymentsList = ({ projectId }: Props) => { + const { filters } = useFilters(); + + 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], + ); + + const [selectedDeployment, setSelectedDeployment] = useState<{ + deployment: Deployment; + environment?: Environment; + } | null>(null); const isCompactView = useIsMobile({ breakpoint: COMPACT_BREAKPOINT }); - const columns: Column[] = useMemo(() => { + const columns: Column<{ deployment: Deployment; environment?: Environment }>[] = useMemo(() => { return [ { key: "deployment_id", header: "Deployment ID", - width: "15%", + width: "20%", headerClassName: "pl-[18px]", - render: (deployment) => { - const isSelected = deployment.id === selectedDeployment?.id; + render: ({ deployment, environment }) => { + const isSelected = deployment.id === selectedDeployment?.deployment.id; const iconContainer = (
{ > {shortenId(deployment.id)}
- {deployment.environment === "production" && deployment.active && ( + {environment?.slug === "production" ? ( - )} + ) : null}
- {deployment.pullRequest?.title ?? "—"} + {environment?.slug}
@@ -91,23 +187,12 @@ export const DeploymentsList = () => { ); }, }, - { - key: "env", - header: "Environment", - width: "15%", - render: (deployment) => { - return ( -
- {deployment.environment} -
- ); - }, - }, + { key: "status", header: "Status", width: "12%", - render: (deployment) => { + render: ({ deployment }) => { return ; }, }, @@ -118,15 +203,18 @@ export const DeploymentsList = () => { key: "instances" as const, header: "Instances", width: "10%", - render: (deployment: Deployment) => { + render: ({ deployment }: { deployment: Deployment }) => { return (
- {deployment.instances} + {deployment.runtimeConfig.regions.reduce( + (acc, region) => acc + region.vmCount, + 0, + )} - {deployment.instances === 1 ? " VM" : " VMs"} + VMs
); @@ -136,19 +224,21 @@ export const DeploymentsList = () => { key: "size" as const, header: "Size", width: "10%", - render: (deployment: Deployment) => { + render: ({ deployment }: { deployment: Deployment }) => { return (
- 2 + + {deployment.runtimeConfig.cpus} + CPU
/
- {deployment.size} + {deployment.runtimeConfig.memory} MB
@@ -161,10 +251,10 @@ export const DeploymentsList = () => { { key: "source", header: "Source", - width: "10%", + width: "20%", headerClassName: "pl-[18px]", - render: (deployment) => { - const isSelected = deployment.id === selectedDeployment?.id; + render: ({ deployment }) => { + const isSelected = deployment.id === selectedDeployment?.deployment.id; const iconContainer = (
{ "text-accent-12", )} > - {deployment.source.branch} + {deployment.gitBranch}
- {deployment.source.gitSha} + {deployment.gitCommitSha}
@@ -206,19 +296,19 @@ export const DeploymentsList = () => { key: "author_created" as const, header: "Author / Created", width: "20%", - render: (deployment: Deployment) => { + render: ({ deployment }: { deployment: Deployment }) => { return (
Author
- {deployment.author.name} + {deployment.gitCommitAuthorUsername}
@@ -237,12 +327,13 @@ export const DeploymentsList = () => { : [ { key: "created_at" as const, - header: "Created at", + header: "Created", width: "10%", - render: (deployment: Deployment) => { + render: ({ deployment }: { deployment: Deployment }) => { return ( ); @@ -252,16 +343,16 @@ export const DeploymentsList = () => { key: "author" as const, header: "Author", width: "10%", - render: (deployment: Deployment) => { + render: ({ deployment }: { deployment: Deployment }) => { return (
Author - {deployment.author.name} + {deployment.gitCommitAuthorName}
); @@ -272,37 +363,22 @@ export const DeploymentsList = () => { key: "action", header: "", width: "auto", - render: (deployment) => { + render: ({ deployment }: { deployment: Deployment }) => { return ; }, }, ]; - }, [selectedDeployment?.id, isCompactView]); + }, [selectedDeployment, isCompactView]); return ( deployment.id} - rowClassName={(deployment) => getRowClassName(deployment, selectedDeployment)} - loadMoreFooterProps={{ - hide: isLoading, - buttonText: "Load more deployments", - hasMore, - countInfoText: ( -
- Showing {deployments.length} - of - {totalCount} - deployments -
- ), - }} + rowClassName={(deployment) => getRowClassName(deployment, selectedDeployment?.deployment.id)} emptyState={
diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/hooks/use-deployments-list-query.ts b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/hooks/use-deployments-list-query.ts deleted file mode 100644 index e78ebbeb24..0000000000 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/hooks/use-deployments-list-query.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { trpc } from "@/lib/trpc/client"; -import type { Deployment } from "@/lib/trpc/routers/deploy/project/deployment/list"; -import { useEffect, useMemo, useState } from "react"; -import { - type DeploymentListQuerySearchParams, - deploymentListFilterFieldConfig, - deploymentListFilterFieldNames, -} from "../../../filters.schema"; -import { useFilters } from "../../../hooks/use-filters"; - -export function useDeploymentsListQuery() { - const [totalCount, setTotalCount] = useState(0); - const [deploymentsMap, setDeploymentsMap] = useState(() => new Map()); - const { filters } = useFilters(); - - const deployments = useMemo(() => Array.from(deploymentsMap.values()), [deploymentsMap]); - - const queryParams = useMemo(() => { - const params: DeploymentListQuerySearchParams = { - status: [], - environment: [], - branch: [], - startTime: null, - endTime: null, - since: null, - }; - - filters.forEach((filter) => { - switch (filter.field) { - case "status": - case "environment": { - if (!deploymentListFilterFieldNames.includes(filter.field) || !params[filter.field]) { - return; - } - const fieldConfig = deploymentListFilterFieldConfig[filter.field]; - const validOperators = fieldConfig.operators; - if (!validOperators.includes(filter.operator)) { - throw new Error("Invalid operator"); - } - if (typeof filter.value === "string") { - params[filter.field]?.push({ - operator: "is", - value: filter.value, - }); - } - break; - } - case "branch": { - if (!deploymentListFilterFieldNames.includes(filter.field) || !params[filter.field]) { - return; - } - const fieldConfig = deploymentListFilterFieldConfig[filter.field]; - const validOperators = fieldConfig.operators; - if (!validOperators.includes(filter.operator)) { - throw new Error("Invalid operator"); - } - if (typeof filter.value === "string") { - params[filter.field]?.push({ - operator: "contains", - value: filter.value, - }); - } - break; - } - case "startTime": - case "endTime": - params[filter.field] = filter.value as number; - break; - case "since": - params.since = filter.value as string; - break; - } - }); - if (params.status?.length === 0) { - params.status = null; - } - if (params.environment?.length === 0) { - params.environment = null; - } - if (params.branch?.length === 0) { - params.branch = null; - } - - return params; - }, [filters]); - - const { - data: deploymentData, - hasNextPage, - fetchNextPage, - isFetchingNextPage, - isLoading: isLoadingInitial, - } = trpc.deploy.project.deployment.list.useInfiniteQuery(queryParams, { - getNextPageParam: (lastPage) => lastPage.nextCursor, - staleTime: 30_000, - refetchOnMount: false, - refetchOnWindowFocus: false, - }); - - useEffect(() => { - if (deploymentData) { - const newMap = new Map(); - deploymentData.pages.forEach((page) => { - page.deployments.forEach((deployment) => { - newMap.set(deployment.id, deployment); - }); - }); - if (deploymentData.pages.length > 0) { - setTotalCount(deploymentData.pages[0].total); - } - setDeploymentsMap(newMap); - } - }, [deploymentData]); - - return { - deployments, - isLoading: isLoadingInitial, - hasMore: hasNextPage, - loadMore: fetchNextPage, - isLoadingMore: isFetchingNextPage, - totalCount, - }; -} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/utils/get-row-class.ts b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/utils/get-row-class.ts index 51ae7ab78e..aba3305b4a 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/utils/get-row-class.ts +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/utils/get-row-class.ts @@ -1,4 +1,5 @@ -import type { Deployment } from "@/lib/trpc/routers/deploy/project/deployment/list"; +import type { Deployment } from "@/lib/collections"; + import { cn } from "@/lib/utils"; export type StatusStyle = { @@ -34,10 +35,11 @@ export const FAILED_STATUS_STYLES = { focusRing: "focus:ring-error-7", }; -export const getRowClassName = (deployment: Deployment, selectedRow: Deployment | null) => { +export const getRowClassName = (deployment: Deployment, selectedDeploymentId?: string) => { const isFailed = deployment.status === "failed"; const style = isFailed ? FAILED_STATUS_STYLES : STATUS_STYLES; - const isSelected = deployment.id === selectedRow?.id; + const isSelected = + typeof selectedDeploymentId !== "undefined" && deployment.id === selectedDeploymentId; return cn( style.base, diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/filters.schema.ts b/apps/dashboard/app/(app)/projects/[projectId]/deployments/filters.schema.ts index 43e266463a..1f0a21ffd6 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/filters.schema.ts +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/filters.schema.ts @@ -9,21 +9,18 @@ import { z } from "zod"; export const DEPLOYMENT_STATUSES = [ "pending", - "downloading_docker_image", - "building_rootfs", - "uploading_rootfs", - "creating_vm", - "booting_vm", - "assigning_domains", - "completed", + "building", + "deploying", + "network", + "ready", "failed", ] as const; // Define grouped statuses for client filtering const GROUPED_DEPLOYMENT_STATUSES = [ "pending", - "building", // represents all building states - "completed", + "deploying", // represents all deploying states + "ready", "failed", ] as const; @@ -93,17 +90,10 @@ export const expandGroupedStatus = (groupedStatus: GroupedDeploymentStatus): Dep switch (groupedStatus) { case "pending": return ["pending"]; - case "building": - return [ - "downloading_docker_image", - "building_rootfs", - "uploading_rootfs", - "creating_vm", - "booting_vm", - "assigning_domains", - ]; - case "completed": - return ["completed"]; + case "deploying": + return ["building", "deploying", "network"]; + case "ready": + return ["ready"]; case "failed": return ["failed"]; default: diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/page.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/page.tsx index a81f5c97be..6e8cfb947e 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/page.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/page.tsx @@ -1,15 +1,18 @@ "use client"; +import { useParams } from "next/navigation"; 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 }>()!; return (
- +
); } 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 479379dcf2..0edf0e2806 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.deploy.project.activeDeployment.buildLogs.useQuery({ + const { data: logsData, isLoading } = trpc.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 121aacd233..9ac98d63ca 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,7 @@ "use client"; -import { trpc } from "@/lib/trpc/client"; +import { collection } from "@/lib/collections"; +import { eq, useLiveQuery } from "@tanstack/react-db"; import { ChevronDown, CircleCheck, @@ -8,14 +9,12 @@ import { CircleXMark, CodeBranch, CodeCommit, - FolderCloud, Layers3, Magnifier, TriangleWarning2, } 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 { useDeploymentLogs } from "./hooks/use-deployment-logs"; import { InfoChip } from "./info-chip"; @@ -27,24 +26,39 @@ const ANIMATION_STYLES = { slideIn: "transition-all duration-500 ease-out", } as const; -export const STATUS_CONFIG = { - success: { variant: "success" as const, icon: CircleCheck, text: "Active" }, - failed: { variant: "error" as const, icon: CircleWarning, text: "Error" }, - pending: { - variant: "warning" as const, - icon: CircleWarning, - text: "Pending", - }, -} as const; +export const statusIndicator = ( + status: "pending" | "building" | "deploying" | "network" | "ready" | "failed", +) => { + switch (status) { + case "pending": + return { variant: "warning" as const, icon: CircleWarning, text: "Queued" }; + case "building": + return { variant: "warning" as const, icon: CircleWarning, text: "Building" }; + case "deploying": + return { variant: "warning" as const, icon: CircleWarning, text: "Deploying" }; + case "network": + return { variant: "warning" as const, icon: CircleWarning, text: "Assigning Domains" }; + case "ready": + return { variant: "success" as const, icon: CircleCheck, text: "Ready" }; + case "failed": + return { variant: "error" as const, icon: CircleWarning, text: "Error" }; + } + + return { variant: "error" as const, icon: CircleWarning, text: "Unknown" }; +}; -export function ActiveDeploymentCard() { - const { activeDeploymentId } = useProjectLayout(); +type Props = { + deploymentId: string; +}; - // Get the cached deployment details - const trpcUtil = trpc.useUtils(); - const deploymentDetails = trpcUtil.deploy.project.activeDeployment.details.getData({ - deploymentId: activeDeploymentId, - }); +export const ActiveDeploymentCard: React.FC = ({ deploymentId }) => { + const { data } = useLiveQuery((q) => + q + .from({ deployment: collection.deployments }) + .where(({ deployment }) => eq(deployment.id, deploymentId)), + ); + + const deployment = data.at(0); const { logFilter, @@ -58,14 +72,13 @@ export function ActiveDeploymentCard() { handleFilterChange, handleSearchChange, scrollRef, - } = useDeploymentLogs({ deploymentId: activeDeploymentId }); + } = useDeploymentLogs({ deploymentId }); - if (!deploymentDetails) { + if (!deployment) { return ; } - const statusConfig = STATUS_CONFIG[deploymentDetails.buildStatus]; - const [imageName, imageTag] = deploymentDetails.image.split(":"); + const statusConfig = statusIndicator(deployment.status); return ( @@ -73,8 +86,8 @@ export function ActiveDeploymentCard() {
-
v_alpha001
-
{deploymentDetails.description}
+
{deployment.id}
+
TODO
@@ -87,13 +100,9 @@ export function ActiveDeploymentCard() {
Created by - {deploymentDetails.author.name} + TODO - {deploymentDetails.author.name} + {deployment.gitCommitAuthorName}
@@ -108,24 +117,18 @@ export function ActiveDeploymentCard() {
- {deploymentDetails.branch} + {deployment.gitBranch} - {deploymentDetails.commit} + {deployment.gitCommitSha}
- using image - -
- {imageName}:{imageTag} -
-
Build logs
@@ -244,4 +247,4 @@ export function ActiveDeploymentCard() {
); -} +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/skeleton.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/skeleton.tsx index 67db5b49b0..829b158cb4 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/skeleton.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/skeleton.tsx @@ -1,7 +1,6 @@ -import { ChevronDown, CodeBranch, CodeCommit, FolderCloud } from "@unkey/icons"; +import { ChevronDown, CircleCheck, CodeBranch, CodeCommit, FolderCloud } from "@unkey/icons"; import { Badge, Button, Card } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; -import { STATUS_CONFIG } from "."; import { StatusIndicator } from "./status-indicator"; export const ActiveDeploymentCardSkeleton = () => ( @@ -17,8 +16,8 @@ export const ActiveDeploymentCardSkeleton = () => (
- {} - {STATUS_CONFIG.success.text} + {} + Loading
diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/domain-row.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/domain-row.tsx index efda47f424..38ea778879 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/details/domain-row.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/domain-row.tsx @@ -1,21 +1,11 @@ -import { CircleCheck, CircleWarning, Link4, ShareUpRight } from "@unkey/icons"; +import { CircleCheck, Link4, ShareUpRight } from "@unkey/icons"; import { Badge } from "@unkey/ui"; -import { cn } from "@unkey/ui/src/lib/utils"; type DomainRowProps = { domain: string; - status: "success" | "error"; - tags: string[]; }; -export function DomainRow({ domain, status, tags }: DomainRowProps) { - const statusConfig = { - success: { variant: "success" as const, icon: CircleCheck }, - error: { variant: "error" as const, icon: CircleWarning }, - }; - - const { variant, icon: StatusIcon } = statusConfig[status]; - +export function DomainRow({ domain }: DomainRowProps) { return (
@@ -23,27 +13,10 @@ export function DomainRow({ domain, status, tags }: DomainRowProps) {
{domain}
-
- {tags.map((tag) => ( - - ))} -
- - + +
); } - -function InfoTag({ label, className }: { label: string; className?: string }) { - return ( -
{label}
- ); -} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/add-env-var-row.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/add-env-var-row.tsx index bda776a0ee..22bfae1432 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/add-env-var-row.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/add-env-var-row.tsx @@ -1,4 +1,3 @@ -import { trpc } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { EnvVarInputs } from "./components/env-var-inputs"; @@ -12,9 +11,7 @@ type AddEnvVarRowProps = { onCancel: () => void; }; -export function AddEnvVarRow({ projectId, getExistingEnvVar, onCancel }: AddEnvVarRowProps) { - const trpcUtils = trpc.useUtils(); - +export function AddEnvVarRow({ getExistingEnvVar, onCancel }: AddEnvVarRowProps) { // TODO: Add mutation when available // const upsertMutation = trpc.deploy.project.envs.upsert.useMutation(); @@ -58,7 +55,8 @@ export function AddEnvVarRow({ projectId, getExistingEnvVar, onCancel }: AddEnvV await new Promise((resolve) => setTimeout(resolve, 500)); // Invalidate to refresh data - await trpcUtils.deploy.project.envs.getEnvs.invalidate({ projectId }); + // TODO + //await trpcUtils.project.envs.getEnvs.invalidate({ projectId }); onCancel(); // Close the add form } catch (error) { diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/components/env-var-form.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/components/env-var-form.tsx index 0845e43fdd..a3f44a455f 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/components/env-var-form.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/components/env-var-form.tsx @@ -1,4 +1,3 @@ -import { trpc } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { type EnvVar, type EnvVarFormData, EnvVarFormSchema } from "../types"; @@ -29,8 +28,7 @@ export function EnvVarForm({ autoFocus = false, className = "w-full flex px-4 py-3 bg-gray-2 border-b border-gray-4 last:border-b-0", }: EnvVarFormProps) { - const trpcUtils = trpc.useUtils(); - + console.debug(projectId); // TODO: Add mutations when available // const upsertMutation = trpc.deploy.project.envs.upsert.useMutation(); @@ -71,7 +69,7 @@ export function EnvVarForm({ await new Promise((resolve) => setTimeout(resolve, 500)); // Invalidate to refresh data - await trpcUtils.deploy.project.envs.getEnvs.invalidate({ projectId }); + // await trpcUtils.deploy.project.envs.getEnvs.invalidate({ projectId }); onSuccess(); } catch (error) { diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/env-var-row.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/env-var-row.tsx index e961433f79..905b34fe9f 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/env-var-row.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/env-var-row.tsx @@ -1,4 +1,3 @@ -import { trpc } from "@/lib/trpc/client"; import { cn } from "@/lib/utils"; import { Eye, EyeSlash, PenWriting3, Trash } from "@unkey/icons"; import { Button } from "@unkey/ui"; @@ -19,8 +18,6 @@ export function EnvVarRow({ envVar, projectId, getExistingEnvVar }: EnvVarRowPro const [isSecretLoading, setIsSecretLoading] = useState(false); const [decryptedValue, setDecryptedValue] = useState(); - const trpcUtils = trpc.useUtils(); - // TODO: Add mutations when available // const deleteMutation = trpc.deploy.project.envs.delete.useMutation(); // const decryptMutation = trpc.deploy.project.envs.decrypt.useMutation(); @@ -37,7 +34,7 @@ export function EnvVarRow({ envVar, projectId, getExistingEnvVar }: EnvVarRowPro await new Promise((resolve) => setTimeout(resolve, 300)); // Invalidate to refresh data - await trpcUtils.deploy.project.envs.getEnvs.invalidate({ projectId }); + // TODO await trpcUtils.deploy.project.envs.getEnvs.invalidate({ projectId }); } catch (error) { console.error("Failed to delete env var:", error); } 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 c0643e7bc7..4e84dc7d99 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,8 @@ type UseEnvVarsManagerProps = { }; export function useEnvVarsManager({ projectId, environment }: UseEnvVarsManagerProps) { - const { data } = trpc.deploy.project.envs.getEnvs.useQuery({ projectId }); + const { data } = trpc.environmentVariables.list.useQuery({ projectId }); + const envVars = data?.[environment] ?? []; // Helper to check for duplicate environment variable keys within the current 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 28c780d23c..1e21004a46 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 @@ -1,4 +1,5 @@ -import { trpc } from "@/lib/trpc/client"; +import { collection } from "@/lib/collections"; +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"; @@ -9,26 +10,34 @@ type ProjectDetailsExpandableProps = { tableDistanceToTop: number; isOpen: boolean; onClose: () => void; - activeDeploymentId: string; + projectId: string; }; export const ProjectDetailsExpandable = ({ tableDistanceToTop, isOpen, onClose, - activeDeploymentId, + projectId, }: ProjectDetailsExpandableProps) => { - const trpcUtil = trpc.useUtils(); - const details = trpcUtil.deploy.project.activeDeployment.details.getData({ - deploymentId: activeDeploymentId, - }); + const query = useLiveQuery((q) => + q + .from({ project: collection.projects }) + .where(({ project }) => eq(project.id, projectId)) - // Shouldn't happen, because layout handles this case - if (!details) { + .join({ deployment: collection.deployments }, ({ deployment, project }) => + eq(deployment.id, project.activeDeploymentId), + ) + .orderBy(({ project }) => project.id, "asc") + .limit(1), + ); + + const data = query.data.at(0); + + if (!data?.deployment) { return null; } - const detailSections = createDetailSections(details); + const detailSections = createDetailSections(data.deployment); return (
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 f677937a53..6d999415e7 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 @@ -1,4 +1,4 @@ -import type { DeploymentDetails } from "@/lib/trpc/routers/deploy/project/active-deployment/getDetails"; +import type { Deployment } from "@/lib/collections"; import { Bolt, ChartActivity, @@ -6,15 +6,12 @@ import { CodeBranch, CodeCommit, Connections, - FolderCloud, - Gear, Github, Grid, Harddrive, Heart, Location2, MessageWriting, - PaperClip2, User, } from "@unkey/icons"; import { Badge, TimestampInfo } from "@unkey/ui"; @@ -32,7 +29,7 @@ export type DetailSection = { items: DetailItem[]; }; -export const createDetailSections = (details: DeploymentDetails): DetailSection[] => [ +export const createDetailSections = (details: Deployment): DetailSection[] => [ { title: "Active deployment", items: [ @@ -41,36 +38,26 @@ export const createDetailSections = (details: DeploymentDetails): DetailSection[ label: "Repository", content: (
- {details.repository.owner}/ - {details.repository.name} + TODO/ TODO
), }, { icon: , label: "Branch", - content: {details.branch}, + content: {details.gitBranch}, }, { icon: , label: "Commit", - content: {details.commit}, + content: {details.gitCommitSha}, }, { icon: , label: "Description", content: (
- {details.description} -
- ), - }, - { - icon: , - label: "Image", - content: ( -
- {details.image} + {details.gitCommitMessage}
), }, @@ -80,11 +67,11 @@ export const createDetailSections = (details: DeploymentDetails): DetailSection[ content: (
{details.author.name} - {details.author.name} + {details.gitCommitAuthorUsername}
), }, @@ -108,7 +95,9 @@ export const createDetailSections = (details: DeploymentDetails): DetailSection[ label: "Instances", content: (
- {details.instances} + + {details.runtimeConfig.regions.reduce((acc, region) => acc + region.vmCount, 0)} + vm
), @@ -119,12 +108,12 @@ export const createDetailSections = (details: DeploymentDetails): DetailSection[ alignment: "start", content: (
- {details.regions.map((region) => ( + {details.runtimeConfig.regions.map((region) => ( - {region} + {region.region} ))}
@@ -135,7 +124,7 @@ export const createDetailSections = (details: DeploymentDetails): DetailSection[ label: "CPU", content: (
- {details.cpu}vCPUs + {details.runtimeConfig.cpus}vCPUs
), }, @@ -144,7 +133,7 @@ export const createDetailSections = (details: DeploymentDetails): DetailSection[ label: "Memory", content: (
- {details.memory}mb + {details.runtimeConfig.memory}mb
), }, @@ -153,7 +142,7 @@ export const createDetailSections = (details: DeploymentDetails): DetailSection[ label: "Storage", content: (
- {details.storage} + 20GB mb
), @@ -166,19 +155,16 @@ export const createDetailSections = (details: DeploymentDetails): DetailSection[
- {details.healthcheck.method} + TODO
- / - - {details.healthcheck.path.replace(/^\/+/, "")} - + /TODO
every
- {details.healthcheck.interval}s + TODOs
@@ -191,18 +177,18 @@ export const createDetailSections = (details: DeploymentDetails): DetailSection[ content: (
- {details.scaling.min} to{" "} - {details.scaling.max} instances + {3} to{" "} + {6} instances
- at {details.scaling.threshold}% CPU - threshold + at 70% CPU threshold
), }, ], }, + /* { title: "Build Info", items: [ @@ -268,4 +254,5 @@ export const createDetailSections = (details: DeploymentDetails): DetailSection[ }, ], }, + */ ]; 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 ef264bf013..f36852e6b2 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/diff/[...compare]/page.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/diff/[...compare]/page.tsx @@ -1,6 +1,8 @@ "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"; @@ -28,13 +30,17 @@ export default function DiffPage({ params }: Props) { // Fetch deployment details if needed in the future // Fetch all deployments for this project - const { data: deploymentsData, isLoading: deploymentsLoading } = trpc.deployment.list.useQuery( - undefined, - { enabled: !!params.projectId }, - ); - const deployments = - deploymentsData?.deployments?.filter((d) => d.project?.id === params.projectId) || []; + const deployments = useLiveQuery((q) => + q + .from({ deployment: collection.deployments }) + .where(({ deployment }) => eq(deployment.projectId, params.projectId)) + .join({ environment: collection.environments }, ({ environment, deployment }) => + eq(environment.id, deployment.environmentId), + ) + .orderBy(({ deployment }) => deployment.createdAt, "desc") + .limit(100), + ); // Fetch the diff data const { @@ -51,33 +57,9 @@ export default function DiffPage({ params }: Props) { }, ); - // Helper function to create human-readable deployment labels - interface DeploymentData { - id: string; - gitCommitSha?: string | null; - gitBranch?: string | null; - environment: string; - createdAt: number; - status: string; - } - - const getDeploymentLabel = (deployment: DeploymentData) => { - const commitSha = deployment.gitCommitSha?.substring(0, 7) || deployment.id.substring(0, 7); - const branch = deployment.gitBranch || "unknown"; - const environment = deployment.environment; - const date = new Date(deployment.createdAt).toLocaleDateString(); - - return { - primary: `${branch}:${commitSha}`, - secondary: `${environment} • ${date}`, - branch, - commitSha, - environment, - status: deployment.status, - }; - }; - - const sortedDeployments = deployments.sort((a, b) => b.createdAt - a.createdAt); + const sortedDeployments = deployments.data.sort( + (a, b) => b.deployment.createdAt - a.deployment.createdAt, + ); const handleCompare = () => { if (selectedFromDeployment && selectedToDeployment) { @@ -141,24 +123,29 @@ export default function DiffPage({ params }: Props) { - {deploymentsLoading ? ( + {deployments.isLoading ? ( Loading deployments... - ) : deployments.length === 0 ? ( + ) : deployments.data.length === 0 ? ( No deployments found ) : ( - sortedDeployments.map((deployment) => { - const label = getDeploymentLabel(deployment); + sortedDeployments.map(({ deployment, environment }) => { + const commitSha = + deployment.gitCommitSha?.substring(0, 7) || + deployment.id.substring(0, 7); + const branch = deployment.gitBranch || "unknown"; + const date = new Date(deployment.createdAt).toLocaleDateString(); + return (
- {label.primary} + {`${branch}:${commitSha}`} - {label.environment} + {environment?.slug}
- {label.secondary} + {environment?.slug} • ${date}
diff --git a/apps/dashboard/app/(app)/projects/[projectId]/diff/page.tsx b/apps/dashboard/app/(app)/projects/[projectId]/diff/page.tsx index a60de96b06..1020b9ac66 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/diff/page.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/diff/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { trpc } from "@/lib/trpc/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"; @@ -15,47 +16,26 @@ export default function DiffSelectionPage(): JSX.Element { const [selectedToDeployment, setSelectedToDeployment] = useState(""); // Fetch all deployments for this project - const { data: deploymentsData, isLoading: deploymentsLoading } = trpc.deployment.list.useQuery( - undefined, - { enabled: !!projectId }, + const deployments = useLiveQuery((q) => + q + .from({ deployment: collection.deployments }) + .where(({ deployment }) => eq(deployment.projectId, params?.projectId)) + .join({ environment: collection.environments }, ({ environment, deployment }) => + eq(environment.id, deployment.environmentId), + ) + .orderBy(({ deployment }) => deployment.createdAt, "desc") + .limit(100), ); - const deployments = - deploymentsData?.deployments?.filter((d) => d.project?.id === projectId) || []; - - // Helper function to create human-readable deployment labels - interface DeploymentData { - id: string; - gitCommitSha?: string | null; - gitBranch?: string | null; - environment: string; - createdAt: number; - status: string; - } - - const getDeploymentLabel = (deployment: DeploymentData) => { - const commitSha = deployment.gitCommitSha?.substring(0, 7) || deployment.id.substring(0, 7); - const branch = deployment.gitBranch || "unknown"; - const environment = deployment.environment; - const date = new Date(deployment.createdAt).toLocaleDateString(); - - return { - primary: `${branch}:${commitSha}`, - secondary: `${environment} • ${date}`, - branch, - commitSha, - environment, - status: deployment.status, - }; - }; - const handleCompare = () => { if (selectedFromDeployment && selectedToDeployment) { router.push(`/projects/${projectId}/diff/${selectedFromDeployment}/${selectedToDeployment}`); } }; - const sortedDeployments = deployments.sort((a, b) => b.createdAt - a.createdAt); + const sortedDeployments = deployments.data.sort( + (a, b) => b.deployment.createdAt - a.deployment.createdAt, + ); return (
@@ -103,23 +83,27 @@ export default function DiffSelectionPage(): JSX.Element { - {deploymentsLoading ? ( + {deployments.isLoading ? ( Loading deployments... - ) : deployments.length === 0 ? ( + ) : deployments.data.length === 0 ? ( No deployments found ) : ( - sortedDeployments.map((deployment) => { - const label = getDeploymentLabel(deployment); + sortedDeployments.map(({ deployment, environment }) => { + const commitSha = + deployment.gitCommitSha?.substring(0, 7) || deployment.id.substring(0, 7); + const branch = deployment.gitBranch || "unknown"; + const date = new Date(deployment.createdAt).toLocaleDateString(); return ( - {label.primary} + {branch}:{commitSha}
- {label.environment === "production" ? ( + {environment?.slug === "production" ? ( ) : ( )} - {label.environment} + {environment?.slug}
- {label.secondary} + + {environment?.slug} • {date} + - {label.status} + {deployment.status}
@@ -287,8 +278,10 @@ export default function DiffSelectionPage(): JSX.Element { From: {(() => { - const deployment = deployments.find((d) => d.id === selectedFromDeployment); - return deployment ? getDeploymentLabel(deployment).primary : "Unknown"; + const deployment = deployments.data.find( + (d) => d.deployment.id === selectedFromDeployment, + ); + return deployment ? deployment.deployment.id : "Unknown"; })()}
@@ -297,8 +290,10 @@ export default function DiffSelectionPage(): JSX.Element { To: {(() => { - const deployment = deployments.find((d) => d.id === selectedToDeployment); - return deployment ? getDeploymentLabel(deployment).primary : "Unknown"; + const deployment = deployments.data.find( + (d) => d.deployment.id === selectedToDeployment, + ); + return deployment ? deployment.deployment.id : "Unknown"; })()}
diff --git a/apps/dashboard/app/(app)/projects/[projectId]/layout-provider.tsx b/apps/dashboard/app/(app)/projects/[projectId]/layout-provider.tsx index b9beffc551..3a0ce064f7 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/layout-provider.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/layout-provider.tsx @@ -4,10 +4,6 @@ type ProjectLayoutContextType = { isDetailsOpen: boolean; setIsDetailsOpen: (open: boolean) => void; - // Active deployment ID for the production environment. - // Must be fetched on the project list screen and passed down to this component. - // Required by ActiveDeploymentCard and ProjectDetailsExpandable components. - activeDeploymentId: string; projectId: string; }; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/layout.tsx b/apps/dashboard/app/(app)/projects/[projectId]/layout.tsx index ebeb05337e..6a1756bf94 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/layout.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/layout.tsx @@ -1,8 +1,7 @@ "use client"; -import { trpc } from "@/lib/trpc/client"; import { DoubleChevronLeft } from "@unkey/icons"; import { Button, InfoTooltip } from "@unkey/ui"; -import { useCallback, useEffect, useState } from "react"; +import { useState } from "react"; import { ProjectDetailsExpandable } from "./details/project-details-expandables"; import { ProjectLayoutContext } from "./layout-provider"; import { ProjectNavigation } from "./navigations/project-navigation"; @@ -23,48 +22,15 @@ type ProjectLayoutProps = { children: React.ReactNode; }; -const FAKE_DEPLOYMENT_ID = "im-a-fake-deployment-id"; const ProjectLayout = ({ projectId, children }: ProjectLayoutProps) => { - const trpcUtil = trpc.useUtils(); const [tableDistanceToTop, setTableDistanceToTop] = useState(0); const [isDetailsOpen, setIsDetailsOpen] = useState(false); - useEffect(() => { - trpcUtil.deploy.project.envs.getEnvs.prefetch({ - projectId, - }); - }, [trpcUtil, projectId]); - - // This will be called on mount to determine the offset to top, then it will prefetch project details and mount project details drawer. - const handleDistanceToTop = useCallback( - async (distanceToTop: number) => { - setTableDistanceToTop(distanceToTop); - - if (distanceToTop !== 0) { - try { - // Only proceed if prefetch succeeds - await trpcUtil.deploy.project.activeDeployment.details.prefetch({ - deploymentId: FAKE_DEPLOYMENT_ID, - }); - - setTimeout(() => { - setIsDetailsOpen(true); - }, 200); - } catch (error) { - console.error("Failed to prefetch project details:", error); - // Don't open the drawer if prefetch fails - } - } - }, - [trpcUtil], - ); - return ( @@ -72,7 +38,7 @@ const ProjectLayout = ({ projectId, children }: ProjectLayoutProps) => {
{
{children}
setIsDetailsOpen(false)} - activeDeploymentId={FAKE_DEPLOYMENT_ID} />
diff --git a/apps/dashboard/app/(app)/projects/[projectId]/navigations/project-navigation.tsx b/apps/dashboard/app/(app)/projects/[projectId]/navigations/project-navigation.tsx index e7d6f95c08..0dd3e89dde 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/navigations/project-navigation.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/navigations/project-navigation.tsx @@ -2,7 +2,8 @@ import { QuickNavPopover } from "@/components/navbar-popover"; import { NavbarActionButton } from "@/components/navigation/action-button"; import { Navbar } from "@/components/navigation/navbar"; -import { trpc } from "@/lib/trpc/client"; +import { collection } from "@/lib/collections"; +import { eq, useLiveQuery } from "@tanstack/react-db"; import { ArrowDottedRotateAnticlockwise, Cube, Dots, ListRadio, Refresh3 } from "@unkey/icons"; import { Button, Separator } from "@unkey/ui"; import { RepoDisplay } from "../../_components/list/repo-display"; @@ -12,20 +13,25 @@ type ProjectNavigationProps = { }; export const ProjectNavigation = ({ projectId }: ProjectNavigationProps) => { - const { data: projectData, isLoading } = trpc.deploy.project.list.useInfiniteQuery( - {}, // No filters needed - { - getNextPageParam: (lastPage) => lastPage.nextCursor, - staleTime: Number.POSITIVE_INFINITY, - refetchOnMount: false, - refetchOnWindowFocus: false, - }, + const projects = useLiveQuery((q) => + q.from({ project: collection.projects }).select(({ project }) => ({ + id: project.id, + name: project.name, + })), ); - const projects = projectData?.pages.flatMap((page) => page.projects) ?? []; - const activeProject = projects.find((p) => p.id === projectId); + const activeProject = useLiveQuery((q) => + q + .from({ project: collection.projects }) + .where(({ project }) => eq(project.id, projectId)) + .select(({ project }) => ({ + id: project.id, + name: project.name, + gitRepositoryUrl: project.gitRepositoryUrl, + })), + ).data.at(0); - if (isLoading) { + if (projects.isLoading) { return ( }> @@ -39,7 +45,7 @@ export const ProjectNavigation = ({ projectId }: ProjectNavigationProps) => { } if (!activeProject) { - throw new Error(`Project with id "${projectId}" not found`); + return
Project not found
; } return ( @@ -55,7 +61,7 @@ export const ProjectNavigation = ({ projectId }: ProjectNavigationProps) => { noop > ({ + items={projects.data.map((project) => ({ id: project.id, label: project.name, href: `/projects/${project.id}`, diff --git a/apps/dashboard/app/(app)/projects/[projectId]/page.tsx b/apps/dashboard/app/(app)/projects/[projectId]/page.tsx index 0cabc2d6f0..062e694866 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/page.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/page.tsx @@ -1,5 +1,8 @@ "use client"; +import { collection } from "@/lib/collections"; +import { eq, useLiveQuery } from "@tanstack/react-db"; import { Cloud, Earth, FolderCloud, Page2 } from "@unkey/icons"; +import { Empty } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; import type { ReactNode } from "react"; import { ActiveDeploymentCard } from "./details/active-deployment-card"; @@ -7,27 +10,29 @@ import { DomainRow } from "./details/domain-row"; import { EnvironmentVariablesSection } from "./details/env-variables-section"; import { useProjectLayout } from "./layout-provider"; -const DOMAINS = [ - { - domain: "api.gateway.com", - status: "success" as const, - tags: ["https", "primary"], - }, - { - domain: "dev.gateway.com", - status: "error" as const, - tags: ["https", "primary"], - }, - { - domain: "staging.gateway.com", - status: "success" as const, - tags: ["https", "primary"], - }, -]; - export default function ProjectDetails() { const { isDetailsOpen, projectId } = useProjectLayout(); + const domains = useLiveQuery((q) => + q.from({ domain: collection.domains }).where(({ domain }) => eq(domain.projectId, projectId)), + ); + + const projects = useLiveQuery((q) => + q.from({ project: collection.projects }).where(({ project }) => eq(project.id, projectId)), + ); + + const project = projects.data.at(0); + + if (!project) { + return ( + + + No Project Found + Project not found + + ); + } + return (
-
- } - title="Active Deployment" - /> - -
+ {project.activeDeploymentId ? ( +
+ } + title="Active Deployment" + /> + +
+ ) : null}
- {DOMAINS.map((domain) => ( - + {domains.data.map((domain) => ( + ))}
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 b4f7aefb36..8608b92641 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 @@ -44,7 +44,7 @@ export const CreateProjectDialog = () => { await createProject.mutateAsync({ name: values.name, slug: values.slug, - gitRepositoryUrl: values.gitRepositoryUrl || undefined, + gitRepositoryUrl: values.gitRepositoryUrl ?? null, }); } catch (error) { console.error("Form submission error:", error); diff --git a/apps/dashboard/app/(app)/projects/_components/create-project/create-project.schema.ts b/apps/dashboard/app/(app)/projects/_components/create-project/create-project.schema.ts index 859cf5c227..b2e3edae4f 100644 --- a/apps/dashboard/app/(app)/projects/_components/create-project/create-project.schema.ts +++ b/apps/dashboard/app/(app)/projects/_components/create-project/create-project.schema.ts @@ -11,5 +11,5 @@ export const createProjectSchema = z.object({ /^[a-z0-9-]+$/, "Project slug must contain only lowercase letters, numbers, and hyphens", ), - gitRepositoryUrl: z.string().trim().url("Must be a valid URL").optional().or(z.literal("")), + gitRepositoryUrl: z.string().trim().url("Must be a valid URL").nullable().or(z.literal("")), }); diff --git a/apps/dashboard/app/(app)/projects/_components/create-project/use-create-project.ts b/apps/dashboard/app/(app)/projects/_components/create-project/use-create-project.ts index bec434d77b..0213d26c01 100644 --- a/apps/dashboard/app/(app)/projects/_components/create-project/use-create-project.ts +++ b/apps/dashboard/app/(app)/projects/_components/create-project/use-create-project.ts @@ -10,10 +10,8 @@ type CreateProjectResponse = { }; export const useCreateProject = (onSuccess: (data: CreateProjectResponse) => void) => { - const trpcUtils = trpc.useUtils(); - const project = trpc.deploy.project.create.useMutation({ + const project = trpc.project.create.useMutation({ onSuccess(data) { - trpcUtils.deploy.project.list.invalidate(); onSuccess(data); }, onError(err) { diff --git a/apps/dashboard/app/(app)/projects/_components/list/hooks/use-projects-list-query.ts b/apps/dashboard/app/(app)/projects/_components/list/hooks/use-projects-list-query.ts deleted file mode 100644 index 55d829862c..0000000000 --- a/apps/dashboard/app/(app)/projects/_components/list/hooks/use-projects-list-query.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { trpc } from "@/lib/trpc/client"; -import type { Project } from "@/lib/trpc/routers/deploy/project/list"; -import { useEffect, useMemo, useState } from "react"; -import { useProjectsFilters } from "../../hooks/use-projects-filters"; -import { - projectsFilterFieldConfig, - projectsListFilterFieldNames, -} from "../../projects-filters.schema"; -import type { ProjectsQueryPayload } from "../projects-list.schema"; - -export function useProjectsListQuery() { - const [totalCount, setTotalCount] = useState(0); - const [projectsMap, setProjectsMap] = useState(() => new Map()); - const { filters } = useProjectsFilters(); - - const projects = useMemo(() => Array.from(projectsMap.values()), [projectsMap]); - - const queryParams = useMemo(() => { - const params: ProjectsQueryPayload = { - ...Object.fromEntries(projectsListFilterFieldNames.map((field) => [field, []])), - }; - - filters.forEach((filter) => { - if (!projectsListFilterFieldNames.includes(filter.field) || !params[filter.field]) { - return; - } - - const fieldConfig = projectsFilterFieldConfig[filter.field]; - const validOperators = fieldConfig.operators; - - if (!validOperators.includes(filter.operator)) { - throw new Error("Invalid operator"); - } - - if (typeof filter.value === "string") { - params[filter.field]?.push({ - operator: filter.operator, - value: filter.value, - }); - } - }); - - return params; - }, [filters]); - - const { - data: projectData, - hasNextPage, - fetchNextPage, - isFetchingNextPage, - isLoading: isLoadingInitial, - } = trpc.deploy.project.list.useInfiniteQuery(queryParams, { - getNextPageParam: (lastPage) => lastPage.nextCursor, - staleTime: Number.POSITIVE_INFINITY, - refetchOnMount: false, - refetchOnWindowFocus: false, - }); - - useEffect(() => { - if (projectData) { - const newMap = new Map(); - projectData.pages.forEach((page) => { - page.projects.forEach((project) => { - newMap.set(project.id, project); - }); - }); - - if (projectData.pages.length > 0) { - setTotalCount(projectData.pages[0].total); - } - - setProjectsMap(newMap); - } - }, [projectData]); - - return { - projects, - isLoading: isLoadingInitial, - hasMore: hasNextPage, - loadMore: fetchNextPage, - isLoadingMore: isFetchingNextPage, - totalCount, - }; -} diff --git a/apps/dashboard/app/(app)/projects/_components/list/index.tsx b/apps/dashboard/app/(app)/projects/_components/list/index.tsx index 37c04f8499..1ed50c638e 100644 --- a/apps/dashboard/app/(app)/projects/_components/list/index.tsx +++ b/apps/dashboard/app/(app)/projects/_components/list/index.tsx @@ -1,19 +1,19 @@ -import { LoadMoreFooter } from "@/components/virtual-table/components/loading-indicator"; +import { collection } from "@/lib/collections"; +import { useLiveQuery } from "@tanstack/react-db"; import { BookBookmark, Dots } from "@unkey/icons"; import { Button, Empty } from "@unkey/ui"; -import { useProjectsListQuery } from "./hooks/use-projects-list-query"; import { ProjectActions } from "./project-actions"; import { ProjectCard } from "./projects-card"; import { ProjectCardSkeleton } from "./projects-card-skeleton"; const MAX_SKELETON_COUNT = 8; -const MINIMUM_DISPLAY_LIMIT = 10; export const ProjectsList = () => { - const { projects, isLoading, totalCount, hasMore, loadMore, isLoadingMore } = - useProjectsListQuery(); + const projects = useLiveQuery((q) => + q.from({ project: collection.projects }).orderBy(({ project }) => project.updatedAt, "desc"), + ); - if (isLoading) { + if (projects.isLoading) { return (
{ ); } - if (projects.length === 0) { + if (projects.data.length === 0) { return (
@@ -67,22 +67,21 @@ export const ProjectsList = () => { gridTemplateColumns: "repeat(auto-fit, minmax(325px, 350px))", }} > - {projects.map((project) => { - const primaryHostname = project.hostnames[0]?.hostname || "No domain"; + {projects.data.map((project) => { return ( +
- {totalCount > MINIMUM_DISPLAY_LIMIT ? ( - - Viewing - {projects.length} - of - {totalCount} - projects -
- } - /> - ) : null} ); }; diff --git a/apps/dashboard/app/(app)/projects/_components/list/project-actions.tsx b/apps/dashboard/app/(app)/projects/_components/list/project-actions.tsx index 557718e232..d9f02d5559 100644 --- a/apps/dashboard/app/(app)/projects/_components/list/project-actions.tsx +++ b/apps/dashboard/app/(app)/projects/_components/list/project-actions.tsx @@ -1,8 +1,7 @@ "use client"; import { type MenuItem, TableActionPopover } from "@/components/logs/table-action.popover"; -import type { Project } from "@/lib/trpc/routers/deploy/project/list"; -import { Clone, Gear, Layers3, Link4, Trash } from "@unkey/icons"; +import { Clone, Gear, Layers3, Trash } from "@unkey/icons"; import { toast } from "@unkey/ui"; import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"; @@ -10,19 +9,17 @@ import { useRouter } from "next/navigation"; import type { PropsWithChildren } from "react"; type ProjectActionsProps = { - project: Project; + projectId: string; }; -export const ProjectActions = ({ project, children }: PropsWithChildren) => { +export const ProjectActions = ({ projectId, children }: PropsWithChildren) => { const router = useRouter(); - const menuItems = getProjectActionItems(project, router); + const menuItems = getProjectActionItems(projectId, router); return {children}; }; -const getProjectActionItems = (project: Project, router: AppRouterInstance): MenuItem[] => { - const primaryHostname = project.hostnames[0]?.hostname; - +const getProjectActionItems = (projectId: string, router: AppRouterInstance): MenuItem[] => { return [ { id: "favorite-project", @@ -31,17 +28,6 @@ const getProjectActionItems = (project: Project, router: AppRouterInstance): Men onClick: () => {}, divider: true, }, - { - id: "view-project", - label: "View live API", - icon: , - onClick: () => { - if (primaryHostname) { - window.open(`https://${primaryHostname}`, "_blank", "noopener,noreferrer"); - } - }, - disabled: !primaryHostname, - }, { id: "copy-project-id", label: "Copy project ID", @@ -49,7 +35,7 @@ const getProjectActionItems = (project: Project, router: AppRouterInstance): Men icon: , onClick: () => { navigator.clipboard - .writeText(project.id) + .writeText(projectId) .then(() => { toast.success("Project ID copied to clipboard"); }) @@ -67,7 +53,7 @@ const getProjectActionItems = (project: Project, router: AppRouterInstance): Men onClick: () => { //INFO: This will change soon const fakeDeploymentId = "idk"; - router.push(`/projects/${project.id}/deployments/${fakeDeploymentId}/logs`); + router.push(`/projects/${projectId}/deployments/${fakeDeploymentId}/logs`); }, }, { @@ -77,7 +63,7 @@ const getProjectActionItems = (project: Project, router: AppRouterInstance): Men onClick: () => { //INFO: This will change soon const fakeDeploymentId = "idk"; - router.push(`/projects/${project.id}/deployments/${fakeDeploymentId}/settings`); + router.push(`/projects/${projectId}/deployments/${fakeDeploymentId}/settings`); }, divider: true, }, diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-projects-navigation.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-projects-navigation.tsx index d3c18044a0..0afc7e7c3a 100644 --- a/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-projects-navigation.tsx +++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-projects-navigation.tsx @@ -1,39 +1,35 @@ "use client"; import type { NavItem } from "@/components/navigation/sidebar/workspace-navigations"; -import { trpc } from "@/lib/trpc/client"; +import { collection } from "@/lib/collections"; +import { useLiveQuery } from "@tanstack/react-db"; import { useSelectedLayoutSegments } from "next/navigation"; import { useMemo } from "react"; export const useProjectNavigation = (baseNavItems: NavItem[]) => { const segments = useSelectedLayoutSegments() ?? []; - const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = - trpc.deploy.project.list.useInfiniteQuery( - { query: [] }, - { - getNextPageParam: (lastPage) => lastPage.nextCursor, - }, - ); + + const { data, isLoading } = useLiveQuery((q) => + q.from({ project: collection.projects }).orderBy(({ project }) => project.id, "desc"), + ); const projectNavItems = useMemo(() => { - if (!data?.pages) { + if (!data) { return []; } - return data.pages.flatMap((page) => - page.projects.map((project) => { - const currentProjectActive = segments.at(0) === "projects" && segments.at(1) === project.id; + return data.map((project) => { + const currentProjectActive = segments.at(0) === "projects" && segments.at(1) === project.id; - const projectNavItem: NavItem = { - href: `/projects/${project.id}`, - icon: null, - label: project.name, - active: currentProjectActive, - showSubItems: true, - }; + const projectNavItem: NavItem = { + href: `/projects/${project.id}`, + icon: null, + label: project.name, + active: currentProjectActive, + showSubItems: true, + }; - return projectNavItem; - }), - ); - }, [data?.pages, segments]); + return projectNavItem; + }); + }, [data, segments]); const enhancedNavItems = useMemo(() => { const items = [...baseNavItems]; @@ -44,31 +40,14 @@ export const useProjectNavigation = (baseNavItems: NavItem[]) => { projectsItem.showSubItems = true; projectsItem.items = [...(projectsItem.items || []), ...projectNavItems]; - if (hasNextPage) { - projectsItem.items?.push({ - icon: () => null, - href: "#load-more-projects", - label:
More
, - active: false, - loadMoreAction: true, - }); - } - items[projectsItemIndex] = projectsItem; } return items; - }, [baseNavItems, projectNavItems, hasNextPage]); - - const loadMore = () => { - if (!isFetchingNextPage && hasNextPage) { - fetchNextPage(); - } - }; + }, [baseNavItems, projectNavItems]); return { enhancedNavItems, isLoading, - loadMore, }; }; diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/index.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/index.tsx index 4a5a49356c..94f0c6ae44 100644 --- a/apps/dashboard/components/navigation/sidebar/app-sidebar/index.tsx +++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/index.tsx @@ -81,13 +81,11 @@ export function AppSidebar({ [props.workspace, segments], ); - const { enhancedNavItems: apiAddedNavItems, loadMore: loadMoreApis } = - useApiNavigation(baseNavItems); + const { enhancedNavItems: apiAddedNavItems } = useApiNavigation(baseNavItems); const { enhancedNavItems: ratelimitAddedNavItems } = useRatelimitNavigation(apiAddedNavItems); - const { enhancedNavItems: projectAddedNavItems, loadMore: loadMoreProjects } = - useProjectNavigation(ratelimitAddedNavItems); + const { enhancedNavItems: projectAddedNavItems } = useProjectNavigation(ratelimitAddedNavItems); const handleToggleCollapse = useCallback((item: NavItem, isOpen: boolean) => { // Check if this item corresponds to any solo mode route @@ -100,23 +98,6 @@ export function AppSidebar({ } }, []); - const handleLoadMore = useCallback( - (item: NavItem & { loadMoreAction?: boolean }) => { - const loadMoreMap = { - "#load-more-projects": loadMoreProjects, - "#load-more-apis": loadMoreApis, - }; - - const loadMoreFn = loadMoreMap[item.href as keyof typeof loadMoreMap]; - - if (loadMoreFn) { - loadMoreFn(); - } else { - console.error(`Unknown load more action for href: ${item.href}`); - } - }, - [loadMoreApis, loadMoreProjects], - ); const toggleNavItem: NavItem = useMemo( () => ({ label: "Toggle Sidebar", @@ -217,7 +198,6 @@ export function AppSidebar({ > diff --git a/apps/dashboard/lib/collections/deployments.ts b/apps/dashboard/lib/collections/deployments.ts new file mode 100644 index 0000000000..2ea806253f --- /dev/null +++ b/apps/dashboard/lib/collections/deployments.ts @@ -0,0 +1,84 @@ +"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 new file mode 100644 index 0000000000..7cc212c41b --- /dev/null +++ b/apps/dashboard/lib/collections/domains.ts @@ -0,0 +1,60 @@ +"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 new file mode 100644 index 0000000000..cd13229d11 --- /dev/null +++ b/apps/dashboard/lib/collections/environments.ts @@ -0,0 +1,59 @@ +"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; + }, + 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 59cd922622..5fcd6ab171 100644 --- a/apps/dashboard/lib/collections/index.ts +++ b/apps/dashboard/lib/collections/index.ts @@ -1,11 +1,26 @@ "use client"; +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 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"; + export const collection = { ratelimitNamespaces, ratelimitOverrides, + projects, + domains, + deployments, + environments, }; // resets all collections data and preloads new diff --git a/apps/dashboard/lib/collections/projects.ts b/apps/dashboard/lib/collections/projects.ts new file mode 100644 index 0000000000..418babe24a --- /dev/null +++ b/apps/dashboard/lib/collections/projects.ts @@ -0,0 +1,64 @@ +"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 index 00bd5f6c7e..15f150095f 100644 --- a/apps/dashboard/lib/collections/ratelimit_namespaces.ts +++ b/apps/dashboard/lib/collections/ratelimit_namespaces.ts @@ -10,9 +10,9 @@ const schema = z.object({ name: z.string().min(1).max(50), }); -type Schema = z.infer; +export type RatelimitNamespace = z.infer; -export const ratelimitNamespaces = createCollection( +export const ratelimitNamespaces = createCollection( queryCollectionOptions({ queryClient, queryKey: ["ratelimitNamespaces"], diff --git a/apps/dashboard/lib/collections/ratelimit_overrides.ts b/apps/dashboard/lib/collections/ratelimit_overrides.ts index d67d9d35a9..47698eca60 100644 --- a/apps/dashboard/lib/collections/ratelimit_overrides.ts +++ b/apps/dashboard/lib/collections/ratelimit_overrides.ts @@ -12,9 +12,9 @@ const schema = z.object({ limit: z.number(), duration: z.number(), }); -type Schema = z.infer; +export type RatelimitOverride = z.infer; -export const ratelimitOverrides = createCollection( +export const ratelimitOverrides = createCollection( queryCollectionOptions({ queryClient, queryKey: ["ratelimitOverrides"], diff --git a/apps/dashboard/lib/shorten-id.ts b/apps/dashboard/lib/shorten-id.ts index 172afa5c8c..a80bb4c371 100644 --- a/apps/dashboard/lib/shorten-id.ts +++ b/apps/dashboard/lib/shorten-id.ts @@ -1,5 +1,5 @@ /** - * Shortens an ID by keeping a specified number of characters from the start and end, + * Shortens an ID by keeping a specified number of characters from the start (after prefix) and end, * with customizable separator in between. */ export function shortenId( @@ -16,7 +16,7 @@ export function shortenId( } = {}, ): string { const { - startChars = 8, + startChars = 4, endChars = 4, separator = "...", minLength = startChars + endChars + 3, @@ -41,8 +41,14 @@ export function shortenId( return id; } - const start = id.substring(0, startChars); - const end = id.substring(id.length - endChars); - - return `${start}${separator}${end}`; + const [prefix, rest] = id.includes("_") ? id.split("_", 2) : [null, id]; + let s = ""; + if (prefix) { + s += prefix; + s += "_"; + } + s += rest.substring(0, startChars); + s += separator; + s += rest.substring(rest.length - endChars); + return s; } diff --git a/apps/dashboard/lib/trpc/routers/deploy/project/active-deployment/getDetails.ts b/apps/dashboard/lib/trpc/routers/deploy/project/active-deployment/getDetails.ts deleted file mode 100644 index 970c1cc7ac..0000000000 --- a/apps/dashboard/lib/trpc/routers/deploy/project/active-deployment/getDetails.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; -import { z } from "zod"; - -const deploymentDetailsOutputSchema = z.object({ - // Active deployment - repository: z.object({ - owner: z.string(), - name: z.string(), - }), - branch: z.string(), - commit: z.string(), - description: z.string(), - image: z.string(), - author: z.object({ - name: z.string(), - avatar: z.string(), - }), - createdAt: z.number(), - - // Runtime settings - instances: z.number(), - regions: z.array(z.string()), - cpu: z.number(), - memory: z.number(), - storage: z.number(), - healthcheck: z.object({ - method: z.string(), - path: z.string(), - interval: z.number(), - }), - scaling: z.object({ - min: z.number(), - max: z.number(), - threshold: z.number(), - }), - - // Build info - imageSize: z.number(), - buildTime: z.number(), - buildStatus: z.enum(["success", "failed", "pending"]), - baseImage: z.string(), - builtAt: z.number(), -}); - -type DeploymentDetailsOutputSchema = z.infer; -export type DeploymentDetails = z.infer; - -export const getDeploymentDetails = t.procedure - .use(requireUser) - .use(requireWorkspace) - .use(withRatelimit(ratelimit.read)) - .input( - z.object({ - deploymentId: z.string(), - }), - ) - .output(deploymentDetailsOutputSchema) - .query(() => { - //TODO: This should make a db look-up find the "active" and "latest" and "prod" deployment - const details: DeploymentDetailsOutputSchema = { - repository: { - owner: "acme", - name: "acme", - }, - branch: "main", - commit: "e5f6a7b", - description: "Add auth routes + logging", - image: "unkey:latest", - author: { - name: "Oz", - avatar: "https://avatars.githubusercontent.com/u/138932600?s=48&v=4", - }, - createdAt: Date.now(), - - instances: 4, - regions: ["eu-west-2", "us-east-1", "ap-southeast-1"], - cpu: 32, - memory: 512, - storage: 1024, - healthcheck: { - method: "GET", - path: "/health", - interval: 30, - }, - scaling: { - min: 0, - max: 5, - threshold: 80, - }, - - imageSize: 210, - buildTime: 45, - buildStatus: "success", - baseImage: "node:18-alpine", - builtAt: Date.now() - 300000, - }; - - return details; - }); diff --git a/apps/dashboard/lib/trpc/routers/deploy/project/deployment/list.ts b/apps/dashboard/lib/trpc/routers/deploy/project/deployment/list.ts deleted file mode 100644 index 410f1e5973..0000000000 --- a/apps/dashboard/lib/trpc/routers/deploy/project/deployment/list.ts +++ /dev/null @@ -1,383 +0,0 @@ -import { deploymentListInputSchema } from "@/app/(app)/projects/[projectId]/deployments/components/table/deployments.schema"; -import { - DEPLOYMENT_STATUSES, - type DeploymentStatus, -} from "@/app/(app)/projects/[projectId]/deployments/filters.schema"; -import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; -import { TRPCError } from "@trpc/server"; -import { getTimestampFromRelative } from "@unkey/ui/src/lib/utils"; -import { z } from "zod"; - -const Status = z.enum(DEPLOYMENT_STATUSES); - -const AuthorResponse = z.object({ - name: z.string(), - image: z.string(), -}); - -const SourceResponse = z.object({ - branch: z.string(), - gitSha: z.string(), -}); - -const PullRequestResponse = z.object({ - number: z.number(), - title: z.string(), - url: z.string(), -}); - -// Base deployment fields -const BaseDeploymentResponse = z.object({ - id: z.string(), - status: Status, - instances: z.number(), - runtime: z.string().nullable(), - size: z.string().nullable(), - source: SourceResponse, - createdAt: z.number(), - author: AuthorResponse, - description: z.string().nullable(), - pullRequest: PullRequestResponse, -}); - -// Discriminated union for environment-specific deployments -const ProductionDeploymentResponse = BaseDeploymentResponse.extend({ - environment: z.literal("production"), - active: z.boolean(), // Only one production deployment can be active -}); - -const PreviewDeploymentResponse = BaseDeploymentResponse.extend({ - environment: z.literal("preview"), -}); - -const DeploymentResponse = z.discriminatedUnion("environment", [ - ProductionDeploymentResponse, - PreviewDeploymentResponse, -]); - -const deploymentsOutputSchema = z.object({ - deployments: z.array(DeploymentResponse), - hasMore: z.boolean(), - total: z.number(), - nextCursor: z.number().int().nullish(), -}); - -type DeploymentsOutputSchema = z.infer; -export type Deployment = z.infer; - -export const DEPLOYMENTS_LIMIT = 50; - -export const queryDeployments = t.procedure - .use(requireUser) - .use(requireWorkspace) - .use(withRatelimit(ratelimit.read)) - .input(deploymentListInputSchema) - .output(deploymentsOutputSchema) - .query(async ({ input }) => { - try { - const hardcodedDeployments = generateDeployments(10_000); - - // Apply filters to deployments - let filteredDeployments = hardcodedDeployments; - - // Time range filters - let startTime: number | null | undefined = null; - let endTime: number | null | undefined = null; - - if (input.since !== null && input.since !== undefined) { - try { - startTime = getTimestampFromRelative(input.since); - endTime = Date.now(); - } catch { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Invalid since format: ${input.since}. Expected format like "1h", "2d", "30m", "1w".`, - }); - } - } else { - startTime = input.startTime; - endTime = input.endTime; - } - - // Apply time filters - if (startTime !== null && startTime !== undefined) { - filteredDeployments = filteredDeployments.filter( - (deployment) => deployment.createdAt >= startTime, - ); - } - - if (endTime !== null && endTime !== undefined) { - filteredDeployments = filteredDeployments.filter( - (deployment) => deployment.createdAt <= endTime, - ); - } - - // Status filter - expand grouped statuses to actual statuses - if (input.status && input.status.length > 0) { - const expandedStatusValues: DeploymentStatus[] = []; - - input.status.forEach((filter) => { - if (filter.operator !== "is") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Unsupported status operator: ${filter.operator}`, - }); - } - - // Expand grouped status to actual statuses - switch (filter.value) { - case "pending": - expandedStatusValues.push("pending"); - break; - case "building": - expandedStatusValues.push( - "downloading_docker_image", - "building_rootfs", - "uploading_rootfs", - "creating_vm", - "booting_vm", - "assigning_domains", - ); - break; - case "completed": - expandedStatusValues.push("completed"); - break; - case "failed": - expandedStatusValues.push("failed"); - break; - default: - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Unknown grouped status: ${filter.value}`, - }); - } - }); - - filteredDeployments = filteredDeployments.filter((deployment) => - expandedStatusValues.includes(deployment.status), - ); - } - - // Environment filter - if (input.environment && input.environment.length > 0) { - const environmentValues = input.environment.map((filter) => { - if (filter.operator !== "is") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Unsupported environment operator: ${filter.operator}`, - }); - } - return filter.value; - }); - - filteredDeployments = filteredDeployments.filter((deployment) => - environmentValues.includes(deployment.environment), - ); - } - - // Branch filter - if (input.branch && input.branch.length > 0) { - filteredDeployments = filteredDeployments.filter((deployment) => { - return input.branch?.some((filter) => { - if (filter.operator === "is") { - return deployment.source.branch === filter.value; - } - if (filter.operator === "contains") { - return deployment.source.branch.toLowerCase().includes(filter.value.toLowerCase()); - } - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Unsupported branch operator: ${filter.operator}`, - }); - }); - }); - } - - // Apply cursor-based pagination after filtering - if (input.cursor && typeof input.cursor === "number") { - const cursor = input.cursor; - filteredDeployments = filteredDeployments.filter( - (deployment) => deployment.createdAt < cursor, - ); - } - - //Separate active deployment before sorting/pagination - const activeProductionDeployment = filteredDeployments.find( - (deployment): deployment is z.infer => - deployment.environment === "production" && deployment.active === true, - ); - - // Remove active deployment from main list to avoid duplicates - const nonActiveDeployments = filteredDeployments.filter( - (deployment) => !(deployment.environment === "production" && deployment.active === true), - ); - - // Sort non-active deployments by createdAt descending - nonActiveDeployments.sort((a, b) => b.createdAt - a.createdAt); - - // Get total count before pagination - const totalCount = filteredDeployments.length; - - // Apply pagination limit, accounting for active deployment - const remainingSlots = activeProductionDeployment ? DEPLOYMENTS_LIMIT - 1 : DEPLOYMENTS_LIMIT; - const paginatedDeployments = nonActiveDeployments.slice(0, remainingSlots); - - // Combine results with active deployment always first - const finalDeployments: Deployment[] = []; - if (activeProductionDeployment) { - finalDeployments.push(activeProductionDeployment); - } - finalDeployments.push(...paginatedDeployments); - - const hasMore = nonActiveDeployments.length > remainingSlots; - - const response: DeploymentsOutputSchema = { - deployments: finalDeployments, - hasMore, - total: totalCount, - nextCursor: - paginatedDeployments.length > 0 - ? paginatedDeployments[paginatedDeployments.length - 1].createdAt - : null, - }; - - return response; - } catch (error) { - console.error("Error querying deployments:", error); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: - "Failed to retrieve deployments due to an error. If this issue persists, please contact support.", - }); - } - }); - -// Generator function for hardcoded data -const generateDeployments = (count: number): Deployment[] => { - const authors = [ - { - name: "imeyer", - image: "https://avatars.githubusercontent.com/u/78000?v=4", - }, - { - name: "Flo4604", - image: "https://avatars.githubusercontent.com/u/53355483?v=4", - }, - { - name: "ogzhanolguncu", - image: - "https://avatars.githubusercontent.com/u/21091016?s=400&u=788774b6cbffaa93e2b8eadcd10ef32e1c6ecf58&v=4", - }, - { - name: "perkinsjr", - image: "https://avatars.githubusercontent.com/u/45409975?v=4", - }, - { - name: "mcstepp", - image: "https://avatars.githubusercontent.com/u/7390124?v=4", - }, - { - name: "chronark", - image: "https://avatars.githubusercontent.com/u/18246773?v=4", - }, - { - name: "MichaelUnkey", - image: "https://avatars.githubusercontent.com/u/148160799?v=4", - }, - ]; - - const statuses: z.infer[] = [ - "pending", - "downloading_docker_image", - "building_rootfs", - "uploading_rootfs", - "creating_vm", - "booting_vm", - "assigning_domains", - "completed", - "failed", - ]; - - const environments: Array<"production" | "preview"> = ["production", "preview"]; - const branches = ["main", "dev", "feature/auth", "hotfix/security", "staging"]; - const runtimes = ["58", "12", "43", "22", "38", "200", "400", "1000", "35", "362"]; - const sizes = ["512", "1024", "256", "2048", "4096", "8192"]; - const descriptions = [ - "Add auth routes + logging", - "Patch: revert error state", - "Major refactor prep", - "Added rate limit env vars", - "Boot up optimization", - "Initial staging cut", - "Clean up unused modules", - "Old stable fallback", - "Aborted config test", - "Failing on init timeout", - ]; - - const prTitles = [ - "Fix authentication flow and add logging", - "Revert error handling changes", - "Prepare codebase for major refactor", - "Add environment variables for rate limiting", - "Optimize application boot sequence", - "Initial deployment to staging environment", - "Remove unused module dependencies", - "Implement stable fallback mechanism", - "Update configuration settings", - "Fix initialization timeout issues", - ]; - - const deployments: Deployment[] = []; - const baseTime = Date.now(); - let hasActiveProduction = false; - - for (let i = 0; i < count; i++) { - const author = authors[i % authors.length]; - const status = statuses[i % statuses.length]; - const environment = environments[i % environments.length]; - const prNumber = Math.floor(Math.random() * 1000) + 1; - - const baseDeployment = { - id: `deployment_${Math.random().toString(36).substr(2, 16)}`, - status, - instances: Math.floor(Math.random() * 5) + 1, - runtime: status === "completed" ? runtimes[i % runtimes.length] : null, - size: sizes[i % sizes.length], - source: { - branch: branches[i % branches.length], - gitSha: Math.random().toString(36).substr(2, 7), - }, - createdAt: baseTime - i * 1000 * 60 * Math.floor(Math.random() * 60), - author, - description: descriptions[i % descriptions.length], - pullRequest: { - number: prNumber, - title: prTitles[i % prTitles.length], - url: `https://github.com/unkeyed/unkey/pull/${prNumber}`, - }, - }; - - if (environment === "production") { - // Only the first (most recent) production deployment is active - const isActive = !hasActiveProduction; - if (isActive) { - hasActiveProduction = true; - } - - deployments.push({ - ...baseDeployment, - environment: "production" as const, - active: isActive, - status: "completed", - }); - } else { - deployments.push({ - ...baseDeployment, - environment: "preview" as const, - }); - } - } - - return deployments.sort((a, b) => b.createdAt - a.createdAt); -}; diff --git a/apps/dashboard/lib/trpc/routers/deploy/project/list.ts b/apps/dashboard/lib/trpc/routers/deploy/project/list.ts deleted file mode 100644 index ab16576dca..0000000000 --- a/apps/dashboard/lib/trpc/routers/deploy/project/list.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { projectsQueryPayload as projectsInputSchema } from "@/app/(app)/projects/_components/list/projects-list.schema"; -import { - and, - count, - db, - desc, - eq, - exists, - inArray, - isNotNull, - isNull, - like, - lt, - or, - schema, -} from "@/lib/db"; -import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; -import { TRPCError } from "@trpc/server"; -import { z } from "zod"; - -const HostnameResponse = z.object({ - id: z.string(), - hostname: z.string(), -}); - -const ProjectResponse = z.object({ - id: z.string(), - name: z.string(), - slug: z.string(), - gitRepositoryUrl: z.string().nullable(), - branch: z.string().nullable(), - deleteProtection: z.boolean().nullable(), - createdAt: z.number(), - updatedAt: z.number().nullable(), - hostnames: z.array(HostnameResponse), -}); - -const projectsOutputSchema = z.object({ - projects: z.array(ProjectResponse), - hasMore: z.boolean(), - total: z.number(), - nextCursor: z.number().int().nullish(), -}); - -type ProjectsOutputSchema = z.infer; -export type Project = z.infer; - -export const PROJECTS_LIMIT = 10; - -export const queryProjects = t.procedure - .use(requireUser) - .use(requireWorkspace) - .use(withRatelimit(ratelimit.read)) - .input(projectsInputSchema) - .output(projectsOutputSchema) - .query(async ({ ctx, input }) => { - // Build base conditions - const baseConditions = [eq(schema.projects.workspaceId, ctx.workspace.id)]; - - // Add cursor condition for pagination - if (input.cursor && typeof input.cursor === "number") { - const cursorDate = input.cursor; - const sql = or( - // updatedAt exists and is less than cursor - and(isNotNull(schema.projects.updatedAt), lt(schema.projects.updatedAt, cursorDate)), - // updatedAt is null, use createdAt instead - and(isNull(schema.projects.updatedAt), lt(schema.projects.createdAt, cursorDate)), - ); - - if (!sql) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Invalid cursor: Failed to create pagination condition", - }); - } - - baseConditions.push(sql); - } - const filterConditions = []; - - // Single query field that searches across name, branch, and hostnames - if (input.query && input.query.length > 0) { - const searchConditions = []; - - // Process each query filter - for (const filter of input.query) { - if (filter.operator !== "contains") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Unsupported query operator: ${filter.operator}`, - }); - } - - const searchValue = `%${filter.value}%`; - const queryConditions = []; - - // Search in project name - queryConditions.push(like(schema.projects.name, searchValue)); - - // Search in project branch (defaultBranch) - queryConditions.push(like(schema.projects.defaultBranch, searchValue)); - - // Search in hostnames - queryConditions.push( - exists( - db - .select({ projectId: schema.domains.projectId }) - .from(schema.domains) - .where( - and( - eq(schema.domains.workspaceId, ctx.workspace.id), - eq(schema.domains.projectId, schema.projects.id), - like(schema.domains.domain, searchValue), - ), - ), - ), - ); - - // Combine all search conditions with OR for this specific query value - if (queryConditions.length > 0) { - searchConditions.push(or(...queryConditions)); - } - } - - if (searchConditions.length > 0) { - filterConditions.push(or(...searchConditions)); - } - } - - // Combine all conditions - const allConditions = [...baseConditions, ...filterConditions]; - - try { - const [totalResult, projectsResult] = await Promise.all([ - db - .select({ count: count() }) - .from(schema.projects) - .where(and(...allConditions)), - db.query.projects.findMany({ - where: and(...allConditions), - orderBy: [desc(schema.projects.updatedAt)], - limit: PROJECTS_LIMIT + 1, - columns: { - id: true, - name: true, - slug: true, - gitRepositoryUrl: true, - defaultBranch: true, - deleteProtection: true, - createdAt: true, - updatedAt: true, - }, - }), - ]); - - // Check if we have more results - const hasMore = projectsResult.length > PROJECTS_LIMIT; - const projectsWithoutExtra = hasMore - ? projectsResult.slice(0, PROJECTS_LIMIT) - : projectsResult; - - // Get project IDs for hostname lookup - const projectIds = projectsWithoutExtra.map((p) => p.id); - - // Fetch hostnames for all projects - only .unkey.app domains - const hostnamesResult = - projectIds.length > 0 - ? await db.query.domains.findMany({ - where: and( - eq(schema.domains.workspaceId, ctx.workspace.id), - inArray(schema.domains.projectId, projectIds), - like(schema.domains.domain, "%.unkey.app"), - ), - columns: { - id: true, - projectId: true, - domain: true, - }, - orderBy: [desc(schema.projects.updatedAt), desc(schema.projects.createdAt)], - }) - : []; - - // Group hostnames by projectId - const hostnamesByProject = hostnamesResult.reduce( - (acc, hostname) => { - // Make typescript happy, we already ensure this is the case in the drizzle query - const projectId = hostname.projectId; - if (!projectId) { - return acc; - } - - if (!acc[projectId]) { - acc[projectId] = []; - } - acc[projectId].push({ - id: hostname.id, - hostname: hostname.domain, - }); - return acc; - }, - {} as Record>, - ); - - const projects = projectsWithoutExtra.map((project) => ({ - id: project.id, - name: project.name, - slug: project.slug, - gitRepositoryUrl: project.gitRepositoryUrl, - branch: project.defaultBranch, - deleteProtection: project.deleteProtection, - createdAt: project.createdAt, - updatedAt: project.updatedAt, - hostnames: hostnamesByProject[project.id] || [], - })); - - const response: ProjectsOutputSchema = { - projects, - hasMore, - total: totalResult[0]?.count ?? 0, - nextCursor: - hasMore && projects.length > 0 - ? (projects[projects.length - 1].updatedAt ?? projects[projects.length - 1].createdAt) - : null, - }; - - return response; - } catch (error) { - console.error("Error querying projects:", error); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: - "Failed to retrieve projects due to an error. If this issue persists, please contact support.", - }); - } - }); diff --git a/apps/dashboard/lib/trpc/routers/deploy/project/active-deployment/getBuildLogs.ts b/apps/dashboard/lib/trpc/routers/deployment/buildLogs.ts similarity index 100% rename from apps/dashboard/lib/trpc/routers/deploy/project/active-deployment/getBuildLogs.ts rename to apps/dashboard/lib/trpc/routers/deployment/buildLogs.ts diff --git a/apps/dashboard/lib/trpc/routers/deployment/list.ts b/apps/dashboard/lib/trpc/routers/deployment/list.ts index 1f69bce500..499d5f2f1b 100644 --- a/apps/dashboard/lib/trpc/routers/deployment/list.ts +++ b/apps/dashboard/lib/trpc/routers/deployment/list.ts @@ -8,33 +8,26 @@ export const listDeployments = t.procedure .query(async ({ ctx }) => { try { // Get all deployments for this workspace with project info - const deployments = await db.query.deployments.findMany({ + return await db.query.deployments.findMany({ where: (table, { eq }) => eq(table.workspaceId, ctx.workspace.id), - orderBy: (table, { desc }) => [desc(table.createdAt)], - with: { - environment: { columns: { slug: true } }, - project: { columns: { id: true, name: true, slug: true } }, + 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: 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) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", diff --git a/apps/dashboard/lib/trpc/routers/deploy/project/deployment/llm-search/index.ts b/apps/dashboard/lib/trpc/routers/deployment/llm-search/index.ts similarity index 91% rename from apps/dashboard/lib/trpc/routers/deploy/project/deployment/llm-search/index.ts rename to apps/dashboard/lib/trpc/routers/deployment/llm-search/index.ts index ce617e35a8..7fda7a77dc 100644 --- a/apps/dashboard/lib/trpc/routers/deploy/project/deployment/llm-search/index.ts +++ b/apps/dashboard/lib/trpc/routers/deployment/llm-search/index.ts @@ -10,7 +10,7 @@ const openai = env().OPENAI_API_KEY }) : null; -export const deploymentListLlmSearch = t.procedure +export const searchDeployments = t.procedure .use(requireUser) .use(requireWorkspace) .use(withLlmAccess()) diff --git a/apps/dashboard/lib/trpc/routers/deploy/project/deployment/llm-search/utils.ts b/apps/dashboard/lib/trpc/routers/deployment/llm-search/utils.ts similarity index 100% rename from apps/dashboard/lib/trpc/routers/deploy/project/deployment/llm-search/utils.ts rename to apps/dashboard/lib/trpc/routers/deployment/llm-search/utils.ts diff --git a/apps/dashboard/lib/trpc/routers/domains/list.ts b/apps/dashboard/lib/trpc/routers/domains/list.ts new file mode 100644 index 0000000000..7a280745af --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/domains/list.ts @@ -0,0 +1,28 @@ +import { db } from "@/lib/db"; +import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; + +export const listDomains = t.procedure + .use(requireUser) + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .query(async ({ ctx }) => { + return await db.query.domains + .findMany({ + where: (table, { eq }) => eq(table.workspaceId, ctx.workspace.id), + columns: { + id: true, + domain: true, + projectId: true, + type: true, + }, + }) + .catch((error) => { + console.error("Error querying domains:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "Failed to retrieve domains due to an error. If this issue persists, please contact support.", + }); + }); + }); diff --git a/apps/dashboard/lib/trpc/routers/environment/list.ts b/apps/dashboard/lib/trpc/routers/environment/list.ts new file mode 100644 index 0000000000..b2bdab433a --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/environment/list.ts @@ -0,0 +1,24 @@ +import { db } from "@/lib/db"; +import { TRPCError } from "@trpc/server"; +import { requireUser, requireWorkspace, t } from "../../trpc"; + +export const listEnvironments = t.procedure + .use(requireUser) + .use(requireWorkspace) + .query(async ({ ctx }) => { + try { + return await db.query.environments.findMany({ + where: (table, { eq }) => eq(table.workspaceId, ctx.workspace.id), + columns: { + id: true, + projectId: true, + slug: true, + }, + }); + } catch (_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 7d4eea8633..2f4ab8f170 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -37,14 +37,12 @@ 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 "./deploy/project/active-deployment/getBuildLogs"; -import { getDeploymentDetails } from "./deploy/project/active-deployment/getDetails"; -import { createProject } from "./deploy/project/create"; -import { queryDeployments } from "./deploy/project/deployment/list"; -import { deploymentListLlmSearch } from "./deploy/project/deployment/llm-search"; -import { getEnvs } from "./deploy/project/envs/list"; -import { queryProjects } from "./deploy/project/list"; -import { deploymentRouter } from "./deployment"; +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 { listEnvironments } from "./environment/list"; import { createIdentity } from "./identity/create"; import { queryIdentities } from "./identity/query"; import { searchIdentities } from "./identity/search"; @@ -82,6 +80,9 @@ 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"; @@ -310,24 +311,25 @@ export const router = t.router({ query: queryIdentities, search: searchIdentities, }), - deploy: t.router({ - project: t.router({ - list: queryProjects, - create: createProject, - activeDeployment: t.router({ - details: getDeploymentDetails, - buildLogs: getDeploymentBuildLogs, - }), - envs: t.router({ - getEnvs, - }), - deployment: t.router({ - list: queryDeployments, - search: deploymentListLlmSearch, - }), - }), + 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, }), - deployment: deploymentRouter, }); // export type definition of API diff --git a/apps/dashboard/lib/trpc/routers/deploy/project/create.ts b/apps/dashboard/lib/trpc/routers/project/create.ts similarity index 97% rename from apps/dashboard/lib/trpc/routers/deploy/project/create.ts rename to apps/dashboard/lib/trpc/routers/project/create.ts index 055755dc0b..5a9050a872 100644 --- a/apps/dashboard/lib/trpc/routers/deploy/project/create.ts +++ b/apps/dashboard/lib/trpc/routers/project/create.ts @@ -70,8 +70,12 @@ export const createProject = t.procedure workspaceId, name: input.name, slug: input.slug, + activeDeploymentId: null, gitRepositoryUrl: input.gitRepositoryUrl || null, + defaultBranch: "main", deleteProtection: false, + createdAt: now, + updatedAt: now, }); await insertAuditLogs(tx, { diff --git a/apps/dashboard/lib/trpc/routers/deploy/project/envs/list.ts b/apps/dashboard/lib/trpc/routers/project/envs/list.ts similarity index 100% rename from apps/dashboard/lib/trpc/routers/deploy/project/envs/list.ts rename to apps/dashboard/lib/trpc/routers/project/envs/list.ts diff --git a/apps/dashboard/lib/trpc/routers/project/list.ts b/apps/dashboard/lib/trpc/routers/project/list.ts new file mode 100644 index 0000000000..71351fc4b8 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/project/list.ts @@ -0,0 +1,30 @@ +import { db } from "@/lib/db"; +import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; + +export const listProjects = t.procedure + .use(requireUser) + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .query(async ({ ctx }) => { + return await db.query.projects + .findMany({ + where: (table, { eq }) => eq(table.workspaceId, ctx.workspace.id), + columns: { + id: true, + name: true, + slug: true, + updatedAt: true, + gitRepositoryUrl: true, + activeDeploymentId: true, + }, + }) + .catch((error) => { + console.error("Error querying projects:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "Failed to retrieve projects due to an error. If this issue persists, please contact support.", + }); + }); + }); diff --git a/go/pkg/db/schema.sql b/go/pkg/db/schema.sql index ae4bd0b352..ab014422a7 100644 --- a/go/pkg/db/schema.sql +++ b/go/pkg/db/schema.sql @@ -313,6 +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), `default_branch` varchar(256) DEFAULT 'main', `delete_protection` boolean DEFAULT false, `created_at` bigint NOT NULL, diff --git a/internal/db/src/schema/projects.ts b/internal/db/src/schema/projects.ts index 70447eeacf..b9551e5c04 100644 --- a/internal/db/src/schema/projects.ts +++ b/internal/db/src/schema/projects.ts @@ -16,6 +16,9 @@ export const projects = mysqlTable( // Git configuration 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 }), defaultBranch: varchar("default_branch", { length: 256 }).default("main"), ...deleteProtection,