diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/[deploymentId]/page.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/[deploymentId]/page.tsx deleted file mode 100644 index e6799fed31..0000000000 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/[deploymentId]/page.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { OptIn } from "@/components/opt-in"; -import { getAuth } from "@/lib/auth"; -import { db } from "@/lib/db"; -import { redirect } from "next/navigation"; -import { Suspense } from "react"; - -export default async function DeploymentsPage(): Promise { - const { orgId } = await getAuth(); - const workspace = await db.query.workspaces.findFirst({ - where: (table, { and, eq, isNull }) => and(eq(table.orgId, orgId), isNull(table.deletedAtM)), - }); - - if (!workspace) { - return redirect("/new"); - } - - if (!workspace.betaFeatures.deployments) { - // right now, we want to block all external access to deploy - // to make it easier to opt-in for local development, comment out the redirect - // and uncomment the component - //return redirect("/apis"); - return ; - } - - return ( - Loading...}> -
Deployment Details coming soon
-
- ); -} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/control-cloud/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/control-cloud/index.tsx new file mode 100644 index 0000000000..8de918767c --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/control-cloud/index.tsx @@ -0,0 +1,38 @@ +import { HISTORICAL_DATA_WINDOW } from "@/components/logs/constants"; +import { ControlCloud } from "@unkey/ui"; +import type { DeploymentListFilterField } from "../../filters.schema"; +import { useFilters } from "../../hooks/use-filters"; + +const FIELD_DISPLAY_NAMES: Record = { + status: "Status", + environment: "Environment", + branch: "Branch", + startTime: "Start Time", + endTime: "End Time", + since: "Since", +} as const; + +const formatFieldName = (field: string): string => { + if (field in FIELD_DISPLAY_NAMES) { + return FIELD_DISPLAY_NAMES[field as DeploymentListFilterField]; + } + // Fallback for any missing fields + return field + .replace(/([A-Z])/g, " $1") + .replace(/^./, (str) => str.toUpperCase()) + .trim(); +}; + +export const DeploymentsListControlCloud = () => { + const { filters, updateFilters, removeFilter } = useFilters(); + + return ( + + ); +}; 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 new file mode 100644 index 0000000000..72966c72bb --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-datetime/index.tsx @@ -0,0 +1,84 @@ +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 { useFilters } from "../../../../hooks/use-filters"; + +export const DeploymentListDatetime = () => { + const [title, setTitle] = useState("Last 12 hours"); + const { filters, updateFilters } = useFilters(); + + const timeValues = filters + .filter((f) => ["startTime", "endTime", "since"].includes(f.field)) + .reduce( + (acc, f) => ({ + // biome-ignore lint/performance/noAccumulatingSpread: it's safe to spread + ...acc, + [f.field]: f.value, + }), + {}, + ); + + return ( + { + const activeFilters = filters.filter( + (f) => !["endTime", "startTime", "since"].includes(f.field), + ); + if (since !== undefined) { + updateFilters([ + ...activeFilters, + { + field: "since", + value: since, + id: crypto.randomUUID(), + operator: "is", + }, + ]); + return; + } + if (since === undefined && startTime) { + activeFilters.push({ + field: "startTime", + value: startTime, + id: crypto.randomUUID(), + operator: "is", + }); + if (endTime) { + activeFilters.push({ + field: "endTime", + value: endTime, + id: crypto.randomUUID(), + operator: "is", + }); + } + } + updateFilters(activeFilters); + }} + initialTitle={title ?? ""} + onSuggestionChange={setTitle} + > +
+ +
+
+ ); +}; 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 new file mode 100644 index 0000000000..609c6f15cc --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-filters/components/deployment-status-filter.tsx @@ -0,0 +1,65 @@ +import { FilterCheckbox } from "@/components/logs/checkbox/filter-checkbox"; +import type { GroupedDeploymentStatus } from "../../../../../filters.schema"; +import { deploymentListFilterFieldConfig } from "../../../../../filters.schema"; +import { useFilters } from "../../../../../hooks/use-filters"; + +type StatusOption = { + id: number; + status: GroupedDeploymentStatus; + display: string; + checked: boolean; +}; + +const baseOptions: StatusOption[] = [ + { + id: 1, + status: "pending", + display: "Pending", + checked: false, + }, + { + id: 2, + status: "building", + display: "Building", + checked: false, + }, + { + id: 3, + status: "completed", + display: "Ready", + checked: false, + }, + { + id: 4, + status: "failed", + display: "Failed", + checked: false, + }, +]; + +export const DeploymentStatusFilter = () => { + const { filters, updateFilters } = useFilters(); + const getColorClass = deploymentListFilterFieldConfig.status.getColorClass; + + return ( + ( + <> +
+ {checkbox.display} + + )} + createFilterValue={(option) => ({ + value: option.status, + metadata: { + colorClass: getColorClass?.(option.status), + }, + })} + filters={filters} + updateFilters={updateFilters} + /> + ); +}; 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 new file mode 100644 index 0000000000..26cd54fe97 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-filters/components/environment-filter.tsx @@ -0,0 +1,34 @@ +import { FilterCheckbox } from "@/components/logs/checkbox/filter-checkbox"; +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(); + + return ( + ( +
{checkbox.environment}
+ )} + createFilterValue={(option) => ({ + value: option.environment, + })} + filters={filters} + updateFilters={updateFilters} + /> + ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-filters/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-filters/index.tsx new file mode 100644 index 0000000000..31f3fc0330 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-filters/index.tsx @@ -0,0 +1,118 @@ +import { FiltersPopover } from "@/components/logs/checkbox/filters-popover"; +import { FilterOperatorInput } from "@/components/logs/filter-operator-input"; +import { BarsFilter } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { + type DeploymentListFilterField, + deploymentListFilterFieldConfig, +} from "../../../../filters.schema"; +import { useFilters } from "../../../../hooks/use-filters"; +import { DeploymentStatusFilter } from "./components/deployment-status-filter"; +import { EnvironmentFilter } from "./components/environment-filter"; + +const FIELD_DISPLAY_CONFIG: Record< + Exclude, + { label: string; shortcut: string } +> = { + status: { label: "Status", shortcut: "s" }, + environment: { label: "Environment", shortcut: "e" }, + branch: { label: "Branch", shortcut: "b" }, +} as const; + +const displayableFields: (keyof typeof FIELD_DISPLAY_CONFIG)[] = [ + "status", + "environment", + "branch", +]; + +export const DeploymentListFilters = () => { + const { filters, updateFilters } = useFilters(); + + // Generate filter items only for displayable fields + const filterItems = displayableFields.map((fieldName) => { + const fieldConfig = deploymentListFilterFieldConfig[fieldName]; + const displayConfig = FIELD_DISPLAY_CONFIG[fieldName]; + + // Use checkbox components for status and environment + if (fieldName === "status") { + return { + id: fieldName, + label: displayConfig.label, + shortcut: displayConfig.shortcut, + component: , + }; + } + + if (fieldName === "environment") { + return { + id: fieldName, + label: displayConfig.label, + shortcut: displayConfig.shortcut, + component: , + }; + } + + // Use operator input for other fields (like branch) + const options = fieldConfig.operators.map((op) => ({ + id: op, + label: op, + })); + + const activeFilter = filters.find((f) => f.field === fieldName); + + return { + id: fieldName, + label: displayConfig.label, + shortcut: displayConfig.shortcut, + component: ( + { + // Remove existing filters for this field + const filtersWithoutCurrent = filters.filter((f) => f.field !== fieldName); + // Add new filter + updateFilters([ + ...filtersWithoutCurrent, + { + field: fieldName, + id: crypto.randomUUID(), + operator, + value: text, + }, + ]); + }} + /> + ), + }; + }); + + return ( + +
+ +
+
+ ); +}; 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 new file mode 100644 index 0000000000..06d5b34780 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-search/index.tsx @@ -0,0 +1,59 @@ +import { transformStructuredOutputToFilters } from "@/components/logs/validation/utils/transform-structured-output-filter-format"; +import { trpc } from "@/lib/trpc/client"; +import { LLMSearch, toast } from "@unkey/ui"; +import { useFilters } from "../../../../hooks/use-filters"; + +export const DeploymentListSearch = () => { + const { filters, updateFilters } = useFilters(); + + const queryLLMForStructuredOutput = trpc.deploy.deployment.search.useMutation({ + onSuccess(data) { + if (data?.filters.length === 0 || !data) { + toast.error( + "Please provide more specific search criteria. Your query requires additional details for accurate results.", + { + duration: 8000, + position: "top-right", + style: { + whiteSpace: "pre-line", + }, + }, + ); + return; + } + const transformedFilters = transformStructuredOutputToFilters(data, filters); + updateFilters(transformedFilters); + }, + onError(error) { + const errorMessage = `Unable to process your search request${ + error.message ? `: ${error.message}` : "." + } Please try again or refine your search criteria.`; + toast.error(errorMessage, { + duration: 8000, + position: "top-right", + style: { + whiteSpace: "pre-line", + }, + className: "font-medium", + }); + }, + }); + + return ( + + queryLLMForStructuredOutput.mutateAsync({ + query, + }) + } + /> + ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/index.tsx new file mode 100644 index 0000000000..7f40e4951e --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/index.tsx @@ -0,0 +1,16 @@ +import { ControlsContainer, ControlsLeft } from "@/components/logs/controls-container"; +import { DeploymentListDatetime } from "./components/deployment-list-datetime"; +import { DeploymentListFilters } from "./components/deployment-list-filters"; +import { DeploymentListSearch } from "./components/deployment-list-search"; + +export function DeploymentsListControls() { + return ( + + + + + + + + ); +} 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 new file mode 100644 index 0000000000..3287009cf5 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/actions/deployment-list-table-action.popover.constants.tsx @@ -0,0 +1,32 @@ +"use client"; +import { type MenuItem, TableActionPopover } from "@/components/logs/table-action.popover"; +import type { Deployment } from "@/lib/trpc/routers/deploy/project/deployment/list"; +import { PenWriting3 } from "@unkey/icons"; +import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"; +import { useRouter } from "next/navigation"; + +type DeploymentListTableActionsProps = { + deployment: Deployment; +}; + +export const DeploymentListTableActions = ({ deployment }: DeploymentListTableActionsProps) => { + const router = useRouter(); + const menuItems = getDeploymentListTableActionItems(deployment, router); + return ; +}; + +const getDeploymentListTableActionItems = ( + deployment: Deployment, + router: AppRouterInstance, +): MenuItem[] => { + return [ + { + id: "edit-root-key", + label: "Edit root key...", + icon: , + onClick: () => { + router.push(`/settings/root-keys/${deployment.id}`); + }, + }, + ]; +}; 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 new file mode 100644 index 0000000000..acf652aeaf --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/deployment-status-badge.tsx @@ -0,0 +1,150 @@ +"use client"; +import { + ArrowDotAntiClockwise, + CircleCheck, + CircleDotted, + CircleHalfDottedClock, + CircleWarning, + Cloud, + CloudUp, + HalfDottedCirclePlay, + Nut, +} from "@unkey/icons"; +import type { IconProps } from "@unkey/icons/src/props"; +import { cn } from "@unkey/ui/src/lib/utils"; +import type { FC } from "react"; +import type { DeploymentStatus } from "../../../filters.schema"; + +type StatusConfig = { + icon: FC; + label: string; + bgColor: string; + textColor: string; + iconColor: string; + animated?: boolean; +}; + +const statusConfigs: Record = { + pending: { + icon: CircleHalfDottedClock, + label: "Pending", + bgColor: "bg-grayA-3", + 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: { + icon: Nut, + label: "Building", + bgColor: "bg-gradient-to-r from-infoA-5 to-transparent", + textColor: "text-infoA-11", + 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: { + icon: HalfDottedCirclePlay, + label: "Booting", + bgColor: "bg-gradient-to-r from-infoA-5 to-transparent", + textColor: "text-infoA-11", + iconColor: "text-info-11", + animated: true, + }, + assigning_domains: { + icon: ArrowDotAntiClockwise, + label: "Assigning Domains", + bgColor: "bg-gradient-to-r from-infoA-5 to-transparent", + textColor: "text-infoA-11", + iconColor: "text-info-11", + animated: true, + }, + completed: { + icon: CircleCheck, + label: "Ready", + bgColor: "bg-successA-3", + textColor: "text-successA-11", + iconColor: "text-success-11", + }, + failed: { + icon: CircleWarning, + label: "Failed", + bgColor: "bg-errorA-3", + textColor: "text-errorA-11", + iconColor: "text-error-11", + }, +}; + +type DeploymentStatusBadgeProps = { + status: DeploymentStatus; + className?: string; +}; + +export const DeploymentStatusBadge = ({ status, className }: DeploymentStatusBadgeProps) => { + const config = statusConfigs[status]; + + if (!config) { + throw new Error(`Invalid deployment status: ${status}`); + } + + const { icon: Icon, label, bgColor, textColor, iconColor, animated } = config; + + return ( +
+ {animated && ( +
+ )} + + {label} + + {animated && ( + + )} +
+ ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/env-status-badge.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/env-status-badge.tsx new file mode 100644 index 0000000000..d37d054444 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/env-status-badge.tsx @@ -0,0 +1,41 @@ +import { cn } from "@/lib/utils"; +import { cva } from "class-variance-authority"; +import type { VariantProps } from "class-variance-authority"; +import type { HTMLAttributes, ReactNode } from "react"; + +const statusBadgeVariants = cva( + "inline-flex items-center rounded-md px-2 text-xs leading-5 gap-1", + { + variants: { + variant: { + enabled: "text-successA-11 bg-successA-3", + disabled: "text-warningA-11 bg-warningA-3", + current: "text-feature-11 bg-feature-4", + }, + }, + defaultVariants: { + variant: "current", + }, + }, +); + +interface EnvStatusBadgeProps extends HTMLAttributes { + variant?: VariantProps["variant"]; + icon?: ReactNode; + text: string; +} + +export const EnvStatusBadge = ({ + variant, + icon, + text, + className, + ...props +}: EnvStatusBadgeProps) => { + return ( +
+ {icon && {icon}} + {text} +
+ ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/skeletons.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/skeletons.tsx new file mode 100644 index 0000000000..e0c369da0e --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/components/skeletons.tsx @@ -0,0 +1,89 @@ +import { Cloud, CodeBranch, Cube, Dots } from "@unkey/icons"; +import { cn } from "@unkey/ui/src/lib/utils"; + +export const DeploymentIdColumnSkeleton = () => ( +
+
+
+ +
+
+
+
+
+
+
+); + +export const EnvColumnSkeleton = () => ( +
+
+
+); + +export const StatusColumnSkeleton = () => ( +
+
+
+
+); + +export const InstancesColumnSkeleton = () => ( +
+ +
+
+
+
+
+); + +export const SizeColumnSkeleton = () => ( +
+ +
+
+
+
+
+); + +export const SourceColumnSkeleton = () => ( +
+
+
+ +
+
+
+
+
+
+
+
+
+); + +export const CreatedAtColumnSkeleton = () => ( +
+); + +export const AuthorColumnSkeleton = () => ( +
+
+
+
+); + +export const ActionColumnSkeleton = () => ( + +); 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 new file mode 100644 index 0000000000..aa2156a5d3 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/deployments-list.tsx @@ -0,0 +1,363 @@ +"use client"; +import { VirtualTable } from "@/components/virtual-table/index"; +import type { Column } from "@/components/virtual-table/types"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { shortenId } from "@/lib/shorten-id"; +import type { Deployment } from "@/lib/trpc/routers/deploy/project/deployment/list"; +import { BookBookmark, Cloud, CodeBranch, Cube } from "@unkey/icons"; +import { Button, Empty, TimestampInfo } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import dynamic from "next/dynamic"; +import { useMemo, useState } from "react"; +import { DeploymentStatusBadge } from "./components/deployment-status-badge"; +import { EnvStatusBadge } from "./components/env-status-badge"; +import { + ActionColumnSkeleton, + AuthorColumnSkeleton, + CreatedAtColumnSkeleton, + DeploymentIdColumnSkeleton, + EnvColumnSkeleton, + InstancesColumnSkeleton, + SizeColumnSkeleton, + SourceColumnSkeleton, + StatusColumnSkeleton, +} from "./components/skeletons"; +import { useDeploymentsListQuery } from "./hooks/use-deployments-list-query"; +import { getRowClassName } from "./utils/get-row-class"; + +const DeploymentListTableActions = dynamic( + () => + import("./components/actions/deployment-list-table-action.popover.constants").then( + (mod) => mod.DeploymentListTableActions, + ), + { + loading: () => , + ssr: false, + }, +); + +const COMPACT_BREAKPOINT = 1200; + +export const DeploymentsList = () => { + const { deployments, isLoading, isLoadingMore, loadMore, totalCount, hasMore } = + useDeploymentsListQuery(); + const [selectedDeployment, setSelectedDeployment] = useState(null); + const isCompactView = useIsMobile({ breakpoint: COMPACT_BREAKPOINT }); + + const columns: Column[] = useMemo(() => { + return [ + { + key: "deployment_id", + header: "Deployment ID", + width: "15%", + headerClassName: "pl-[18px]", + render: (deployment) => { + const isSelected = deployment.id === selectedDeployment?.id; + const iconContainer = ( +
+ +
+ ); + return ( +
+
+ {iconContainer} +
+
+
+ {shortenId(deployment.id)} +
+ {deployment.environment === "production" && deployment.active && ( + + )} +
+
+ {deployment.pullRequest?.title ?? "—"} +
+
+
+
+ ); + }, + }, + { + key: "env", + header: "Environment", + width: "15%", + render: (deployment) => { + return ( +
+ {deployment.environment} +
+ ); + }, + }, + { + key: "status", + header: "Status", + width: "12%", + render: (deployment) => { + return ; + }, + }, + ...(isCompactView + ? [] + : [ + { + key: "instances" as const, + header: "Instances", + width: "10%", + render: (deployment: Deployment) => { + return ( +
+ +
+ + {deployment.instances} + + {deployment.instances === 1 ? " VM" : " VMs"} +
+
+ ); + }, + }, + { + key: "size" as const, + header: "Size", + width: "10%", + render: (deployment: Deployment) => { + return ( +
+ +
+
+ 2 + CPU +
+ / +
+ + {deployment.size} + + MB +
+
+
+ ); + }, + }, + ]), + { + key: "source", + header: "Source", + width: "10%", + headerClassName: "pl-[18px]", + render: (deployment) => { + const isSelected = deployment.id === selectedDeployment?.id; + const iconContainer = ( +
+ +
+ ); + return ( +
+
+ {iconContainer} +
+
+
+ {deployment.source.branch} +
+
+
+ {deployment.source.gitSha} +
+
+
+
+ ); + }, + }, + ...(isCompactView + ? [ + { + key: "author_created" as const, + header: "Author / Created", + width: "20%", + render: (deployment: Deployment) => { + return ( +
+
+ Author +
+
+ + {deployment.author.name} + +
+
+ +
+
+
+
+ ); + }, + }, + ] + : [ + { + key: "created_at" as const, + header: "Created at", + width: "10%", + render: (deployment: Deployment) => { + return ( + + ); + }, + }, + { + key: "author" as const, + header: "Author", + width: "10%", + render: (deployment: Deployment) => { + return ( +
+ Author + + {deployment.author.name} + +
+ ); + }, + }, + ]), + { + key: "action", + header: "", + width: "auto", + render: (deployment) => { + return ; + }, + }, + ]; + }, [selectedDeployment?.id, isCompactView]); + + return ( + deployment.id} + rowClassName={(deployment) => getRowClassName(deployment, selectedDeployment)} + loadMoreFooterProps={{ + hide: isLoading, + buttonText: "Load more deployments", + hasMore, + countInfoText: ( +
+ Showing {deployments.length} + of + {totalCount} + deployments +
+ ), + }} + emptyState={ +
+ + + No Deployments Found + + There are no deployments yet. Push to your connected repository or trigger a manual + deployment to get started. + + + + + + + +
+ } + config={{ + rowHeight: 52, + layoutMode: "grid", + rowBorders: true, + containerPadding: "px-0", + }} + renderSkeletonRow={({ columns, rowHeight }) => + columns.map((column) => ( + + {column.key === "deployment_id" && } + {column.key === "env" && } + {column.key === "status" && } + {column.key === "instances" && } + {column.key === "size" && } + {column.key === "source" && } + {column.key === "created_at" && } + {column.key === "author" && } + {column.key === "author_created" && ( +
+ + +
+ )} + {column.key === "action" && } + + )) + } + /> + ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/deployments.schema.ts b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/deployments.schema.ts new file mode 100644 index 0000000000..f02be5b1bf --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/deployments.schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; +import { deploymentListFilterOperatorEnum } from "../../filters.schema"; + +const filterItemSchema = z.object({ + operator: deploymentListFilterOperatorEnum, + value: z.string(), +}); + +export const deploymentListInputSchema = z.object({ + status: z.array(filterItemSchema).nullish(), + environment: z.array(filterItemSchema).nullish(), + branch: z.array(filterItemSchema).nullish(), + startTime: z.number().nullish(), + endTime: z.number().nullish(), + since: z.string().nullish(), + cursor: z.number().nullish(), +}); + +export type DeploymentListInputSchema = z.infer; 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 new file mode 100644 index 0000000000..87f900b58f --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/hooks/use-deployments-list-query.ts @@ -0,0 +1,123 @@ +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.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 new file mode 100644 index 0000000000..51ae7ab78e --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/utils/get-row-class.ts @@ -0,0 +1,50 @@ +import type { Deployment } from "@/lib/trpc/routers/deploy/project/deployment/list"; +import { cn } from "@/lib/utils"; + +export type StatusStyle = { + base: string; + hover: string; + selected: string; + badge: { + default: string; + selected: string; + }; + focusRing: string; +}; + +export const STATUS_STYLES = { + base: "text-grayA-9", + hover: "hover:text-accent-11 dark:hover:text-accent-12 hover:bg-grayA-2", + selected: "text-accent-12 bg-grayA-2 hover:text-accent-12", + badge: { + default: "bg-grayA-3 text-grayA-11 group-hover:bg-grayA-5 border-transparent", + selected: "bg-grayA-5 text-grayA-12 hover:bg-grayA-5 border-grayA-3", + }, + focusRing: "focus:ring-accent-7", +}; + +export const FAILED_STATUS_STYLES = { + base: "text-grayA-9 bg-error-1", + hover: "hover:text-grayA-11 hover:bg-error-2", + selected: "text-grayA-12 bg-error-3 hover:bg-error-3", + badge: { + default: "bg-grayA-3 text-grayA-11 group-hover:bg-grayA-5 border-transparent", + selected: "bg-grayA-5 text-grayA-12 hover:bg-grayA-5 border-grayA-3", + }, + focusRing: "focus:ring-error-7", +}; + +export const getRowClassName = (deployment: Deployment, selectedRow: Deployment | null) => { + const isFailed = deployment.status === "failed"; + const style = isFailed ? FAILED_STATUS_STYLES : STATUS_STYLES; + const isSelected = deployment.id === selectedRow?.id; + + return cn( + style.base, + style.hover, + "group rounded", + "focus:outline-none focus:ring-1 focus:ring-opacity-40", + style.focusRing, + isSelected && style.selected, + ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/filters.schema.ts b/apps/dashboard/app/(app)/projects/[projectId]/deployments/filters.schema.ts new file mode 100644 index 0000000000..43e266463a --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/filters.schema.ts @@ -0,0 +1,156 @@ +import type { + FilterValue, + NumberConfig, + StringConfig, +} from "@/components/logs/validation/filter.types"; +import { parseAsFilterValueArray } from "@/components/logs/validation/utils/nuqs-parsers"; +import { createFilterOutputSchema } from "@/components/logs/validation/utils/structured-output-schema-generator"; +import { z } from "zod"; + +export const DEPLOYMENT_STATUSES = [ + "pending", + "downloading_docker_image", + "building_rootfs", + "uploading_rootfs", + "creating_vm", + "booting_vm", + "assigning_domains", + "completed", + "failed", +] as const; + +// Define grouped statuses for client filtering +const GROUPED_DEPLOYMENT_STATUSES = [ + "pending", + "building", // represents all building states + "completed", + "failed", +] as const; + +const DEPLOYMENT_ENVIRONMENTS = ["production", "preview"] as const; + +export type DeploymentStatus = (typeof DEPLOYMENT_STATUSES)[number]; +export type GroupedDeploymentStatus = (typeof GROUPED_DEPLOYMENT_STATUSES)[number]; +export type DeploymentEnvironment = (typeof DEPLOYMENT_ENVIRONMENTS)[number]; + +const allOperators = ["is", "contains"] as const; + +export const deploymentListFilterOperatorEnum = z.enum(allOperators); +export type DeploymentListFilterOperator = z.infer; + +export type FilterFieldConfigs = { + status: StringConfig; + environment: StringConfig; + branch: StringConfig; + startTime: NumberConfig; + endTime: NumberConfig; + since: StringConfig; +}; + +export const deploymentListFilterFieldConfig: FilterFieldConfigs = { + status: { + type: "string", + operators: ["is"], + validValues: GROUPED_DEPLOYMENT_STATUSES, + getColorClass: (value) => { + if (value === "completed") { + return "bg-success-9"; + } + if (value === "failed") { + return "bg-error-9"; + } + if (value === "pending") { + return "bg-gray-9"; + } + return "bg-info-9"; // building + }, + }, + environment: { + type: "string", + operators: ["is"], + validValues: DEPLOYMENT_ENVIRONMENTS, + }, + branch: { + type: "string", + operators: ["contains"], + }, + startTime: { + type: "number", + operators: ["is"], + }, + endTime: { + type: "number", + operators: ["is"], + }, + since: { + type: "string", + operators: ["is"], + }, +}; + +// Mapping function to expand grouped statuses to actual statuses +export const expandGroupedStatus = (groupedStatus: GroupedDeploymentStatus): DeploymentStatus[] => { + 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 "failed": + return ["failed"]; + default: + throw new Error(`Unknown grouped status: ${groupedStatus}`); + } +}; + +const allFilterFieldNames = Object.keys( + deploymentListFilterFieldConfig, +) as (keyof FilterFieldConfigs)[]; + +if (allFilterFieldNames.length === 0) { + throw new Error("deploymentListFilterFieldConfig must contain at least one field definition."); +} + +const [firstFieldName, ...restFieldNames] = allFilterFieldNames; + +export const deploymentListFilterFieldEnum = z.enum([firstFieldName, ...restFieldNames]); + +export const deploymentListFilterFieldNames = allFilterFieldNames; +export type DeploymentListFilterField = z.infer; + +export const deploymentListFilterOutputSchema = createFilterOutputSchema( + deploymentListFilterFieldEnum, + deploymentListFilterOperatorEnum, + deploymentListFilterFieldConfig, +); + +export type DeploymentListFilterUrlValue = { + value: string; + operator: DeploymentListFilterOperator; +}; + +export type DeploymentListFilterValue = FilterValue< + DeploymentListFilterField, + DeploymentListFilterOperator +>; + +export type DeploymentListQuerySearchParams = { + status: DeploymentListFilterUrlValue[] | null; + environment: DeploymentListFilterUrlValue[] | null; + branch: DeploymentListFilterUrlValue[] | null; + startTime: number | null; + endTime: number | null; + since: string | null; +}; + +export const parseAsAllOperatorsFilterArray = parseAsFilterValueArray( + [...allOperators], +); diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/hooks/use-filters.ts b/apps/dashboard/app/(app)/projects/[projectId]/deployments/hooks/use-filters.ts new file mode 100644 index 0000000000..eeb5774760 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/hooks/use-filters.ts @@ -0,0 +1,127 @@ +import { + parseAsFilterValueArray, + parseAsRelativeTime, +} from "@/components/logs/validation/utils/nuqs-parsers"; +import { parseAsInteger, useQueryStates } from "nuqs"; +import { useCallback, useMemo } from "react"; +import { + type DeploymentListFilterField, + type DeploymentListFilterOperator, + type DeploymentListFilterUrlValue, + type DeploymentListFilterValue, + type DeploymentListQuerySearchParams, + deploymentListFilterFieldConfig, +} from "../filters.schema"; + +const parseAsFilterValArray = parseAsFilterValueArray([ + "is", + "contains", +]); + +export const queryParamsPayload = { + status: parseAsFilterValArray, + environment: parseAsFilterValArray, + branch: parseAsFilterValArray, + startTime: parseAsInteger, + endTime: parseAsInteger, + since: parseAsRelativeTime, +} as const; + +const arrayFields = ["status", "environment", "branch"] as const; +const timeFields = ["startTime", "endTime", "since"] as const; + +export const useFilters = () => { + const [searchParams, setSearchParams] = useQueryStates(queryParamsPayload, { + history: "push", + }); + + const filters = useMemo(() => { + const activeFilters: DeploymentListFilterValue[] = []; + + // Handle array filters + arrayFields.forEach((field) => { + searchParams[field]?.forEach((item) => { + activeFilters.push({ + id: crypto.randomUUID(), + field, + operator: item.operator, + value: item.value, + metadata: deploymentListFilterFieldConfig[field].getColorClass + ? { + colorClass: deploymentListFilterFieldConfig[field].getColorClass( + item.value as string, + ), + } + : undefined, + }); + }); + }); + + // Handle time filters + ["startTime", "endTime", "since"].forEach((field) => { + const value = searchParams[field as keyof DeploymentListQuerySearchParams]; + if (value !== null && value !== undefined) { + activeFilters.push({ + id: crypto.randomUUID(), + field: field as DeploymentListFilterField, + operator: "is", + value: value as string | number, + }); + } + }); + + return activeFilters; + }, [searchParams]); + + const updateFilters = useCallback( + (newFilters: DeploymentListFilterValue[]) => { + const newParams: Partial = Object.fromEntries([ + ...arrayFields.map((field) => [field, null]), + ...timeFields.map((field) => [field, null]), + ]); + + const filterGroups = arrayFields.reduce( + (acc, field) => { + acc[field] = []; + return acc; + }, + {} as Record<(typeof arrayFields)[number], DeploymentListFilterUrlValue[]>, + ); + + newFilters.forEach((filter) => { + if (arrayFields.includes(filter.field as (typeof arrayFields)[number])) { + filterGroups[filter.field as (typeof arrayFields)[number]].push({ + value: filter.value as string, + operator: filter.operator, + }); + } else if (filter.field === "startTime" || filter.field === "endTime") { + newParams[filter.field] = filter.value as number; + } else if (filter.field === "since") { + newParams.since = filter.value as string; + } + }); + + // Set array filters + arrayFields.forEach((field) => { + newParams[field] = filterGroups[field].length > 0 ? filterGroups[field] : null; + }); + + setSearchParams(newParams); + }, + [setSearchParams], + ); + + const removeFilter = useCallback( + (id: string) => { + const newFilters = filters.filter((f) => f.id !== id); + updateFilters(newFilters); + }, + [filters, updateFilters], + ); + + return { + filters, + removeFilter, + updateFilters, + }; +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/navigation.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/navigation.tsx new file mode 100644 index 0000000000..be821d8cd7 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/navigation.tsx @@ -0,0 +1,79 @@ +"use client"; +import { QuickNavPopover } from "@/components/navbar-popover"; +import { Navbar } from "@/components/navigation/navbar"; +import { trpc } from "@/lib/trpc/client"; +import { Cube, Refresh3 } from "@unkey/icons"; +import { RepoDisplay } from "../../_components/list/repo-display"; + +type DeploymentsNavigationProps = { + projectId: string; +}; + +export const DeploymentsNavigation = ({ projectId }: DeploymentsNavigationProps) => { + 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 = projectData?.pages.flatMap((page) => page.projects) ?? []; + const activeProject = projects.find((p) => p.id === projectId); + + if (isLoading) { + return ( + + }> + Projects + +
+ + + + ); + } + + if (!activeProject) { + throw new Error(`Project with id "${projectId}" not found`); + } + + return ( + + }> + Projects + + ({ + id: project.id, + label: project.name, + href: `/projects/${project.id}`, + }))} + shortcutKey="N" + > +
{activeProject.name}
+
+
+
+ {activeProject.gitRepositoryUrl && ( +
+ + Auto-deploys from pushes to + +
+ )} +
+ ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/page.tsx b/apps/dashboard/app/(app)/projects/[projectId]/page.tsx index f27d08c6a8..1d302611f0 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/page.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/page.tsx @@ -1,462 +1,22 @@ "use client"; -import { trpc } from "@/lib/trpc/client"; -import { Button } from "@unkey/ui"; -import { - Activity, - ArrowLeft, - ChevronRight, - Clock, - ExternalLink, - Eye, - FolderOpen, - GitCommit, - Globe, - MoreVertical, - Play, - Plus, - RotateCcw, - Search, - Settings, - Tag, -} from "lucide-react"; -import { useParams } from "next/navigation"; -import { useState } from "react"; - -// Type definitions - -export default function ProjectDetailPage(): JSX.Element { - const params = useParams(); - const projectId = params?.projectId as string; - const [activeTab, setActiveTab] = useState<"overview" | "deployments" | "settings">("overview"); - const [searchTerm, setSearchTerm] = useState(""); - - // Get project deployments - const { data, isLoading, error } = trpc.deployment.listByProject.useQuery( - { projectId }, - { - enabled: !!projectId, - }, - ); - - // Handle invalid project ID - if (!projectId) { - return ( -
-
-

Invalid Project ID

-

The project URL is malformed.

- - - Back to Projects - -
-
- ); - } - - // Handle loading state - if (isLoading) { - return ( -
-
-
- Loading project... -
-
- ); - } - - // Handle error state - if (error) { - return ( -
-
-

Error Loading Project

-

Failed to load project: {error.message}

-
- - - Back to Projects - - -
-
-
- ); - } - - // Handle no data - if (!data) { - return ( -
-
-

Project not found

-

The project you're looking for doesn't exist.

- - - Back to Projects - -
-
- ); - } - - // Get project and deployments from the query - const project = data?.project || null; - const deployments = data?.deployments || []; - - const filteredDeployments = deployments.filter( - (deployment) => - deployment.gitBranch?.toLowerCase().includes(searchTerm.toLowerCase()) || - deployment.gitCommitSha?.toLowerCase().includes(searchTerm.toLowerCase()), - ); - - const getStatusColor = (status: string): string => { - switch (status) { - case "active": - return "text-success bg-success/10 border-success/20"; - case "deploying": - return "text-warn bg-warn/10 border-warn/20"; - case "failed": - return "text-alert bg-alert/10 border-alert/20"; - case "pending": - return "text-warn bg-warn/10 border-warn/20"; - case "archived": - return "text-content-subtle bg-background-subtle border-border"; - default: - return "text-content-subtle bg-background-subtle border-border"; - } - }; - +import { DeploymentsListControlCloud } from "./deployments/components/control-cloud"; +import { DeploymentsListControls } from "./deployments/components/controls"; +import { DeploymentsList } from "./deployments/components/table/deployments-list"; +import { DeploymentsNavigation } from "./deployments/navigation"; + +export default function Deployments({ + params: { projectId }, +}: { + params: { projectId: string }; +}) { return ( -
-
- {/* Header */} -
- - -
-
-
- -
-
-

- {project?.name || "Unknown Project"} -

-
- - {project?.slug || "unknown-slug"} - - {project?.gitRepositoryUrl && ( - - - Repository - - - )} -
-
-
- -
- - -
-
-
- - {/* Tabs */} -
- -
- - {/* Tab Content */} - {activeTab === "overview" && ( -
- {/* Stats Cards */} -
-
-
-
-

Production

-

- {deployments.filter((d) => d.environment === "production").length} -

-
- -
-
- -
-
-
-

Total Deployments

-

{deployments.length}

-
- -
-
- -
-
-
-

Active Deployments

-

- {deployments.filter((d) => d.status === "active").length} -

-
- -
-
- -
-
-
-

Last Updated

-

- {project?.updatedAt || project?.createdAt - ? new Date(project.updatedAt || project.createdAt).toLocaleDateString() - : "Unknown"} -

-
- -
-
-
- - {/* Recent Activity */} -
-

Recent Deployments

- {deployments.length > 0 ? ( - - ) : ( -

No deployments found.

- )} -
- - {/* Quick Actions */} - -
- )} - - {activeTab === "deployments" && ( -
- {/* Search */} -
-
- - ) => - setSearchTerm(e.target.value) - } - className="w-full pl-10 pr-4 py-2 border border-border rounded-lg focus:ring-2 focus:ring-brand focus:border-transparent bg-white text-content" - /> -
-
- - {/* Content */} -
-
-
- - - - - - - - - - - - - {filteredDeployments.map((deployment) => ( - - - - - - - - - ))} - -
- Deployment - - Branch - - Environment - - Status - - Created - - Actions -
-
- - - {deployment.gitCommitSha} - -
-
- {deployment.gitBranch} - - - {deployment.environment} - - - - {deployment.status} - - - {new Date(deployment.createdAt).toLocaleDateString()} - -
- - - - - -
-
-
-
-
-
- )} - - {activeTab === "settings" && ( -
-

Project Settings

-

Settings configuration coming soon...

-
- )} +
+ +
+ + +
); diff --git a/apps/dashboard/app/(app)/projects/_components/list/projects-card.tsx b/apps/dashboard/app/(app)/projects/_components/list/projects-card.tsx index 96723f1403..c097b496db 100644 --- a/apps/dashboard/app/(app)/projects/_components/list/projects-card.tsx +++ b/apps/dashboard/app/(app)/projects/_components/list/projects-card.tsx @@ -1,7 +1,8 @@ import { CodeBranch, Cube, User } from "@unkey/icons"; -import { InfoTooltip } from "@unkey/ui"; +import { InfoTooltip, Loading } from "@unkey/ui"; import Link from "next/link"; import type { ReactNode } from "react"; +import { useCallback, useState } from "react"; import { RegionBadges } from "./region-badges"; type ProjectCardProps = { @@ -29,6 +30,12 @@ export const ProjectCard = ({ actions, projectId, }: ProjectCardProps) => { + const [isNavigating, setIsNavigating] = useState(false); + + const handleLinkClick = useCallback(() => { + setIsNavigating(true); + }, []); + return (
{/* Invisible base clickable layer - covers entire card */} @@ -36,12 +43,16 @@ export const ProjectCard = ({ href={`/projects/${projectId}`} className="absolute inset-0 z-0" aria-label={`View ${name} project`} + onClick={handleLinkClick} /> - {/*Top Section*/}
- + {isNavigating ? ( + + ) : ( + + )}
{/*Top Section > Project Name*/} diff --git a/apps/dashboard/app/(app)/projects/_components/list/region-badges.tsx b/apps/dashboard/app/(app)/projects/_components/list/region-badges.tsx index dc43560a39..72b0cc00ca 100644 --- a/apps/dashboard/app/(app)/projects/_components/list/region-badges.tsx +++ b/apps/dashboard/app/(app)/projects/_components/list/region-badges.tsx @@ -1,5 +1,6 @@ -import { Earth, Github } from "@unkey/icons"; +import { Earth } from "@unkey/icons"; import { InfoTooltip } from "@unkey/ui"; +import { RepoDisplay } from "./repo-display"; type RegionBadgesProps = { regions: string[]; @@ -41,12 +42,10 @@ export const RegionBadges = ({ regions, repository }: RegionBadgesProps) => { )} {repository && ( - -
- - {repository} -
-
+ )}
); diff --git a/apps/dashboard/app/(app)/projects/_components/list/repo-display.tsx b/apps/dashboard/app/(app)/projects/_components/list/repo-display.tsx new file mode 100644 index 0000000000..81cbbc1c06 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/_components/list/repo-display.tsx @@ -0,0 +1,52 @@ +import { Github } from "@unkey/icons"; +import { InfoTooltip } from "@unkey/ui"; +import type { ReactNode } from "react"; + +type RepositoryDisplayProps = { + url: string; + className?: string; + showIcon?: boolean; + children?: ReactNode; +}; + +export const RepoDisplay = ({ + url, + className = "", + showIcon = true, + children, +}: RepositoryDisplayProps) => { + const repoName = extractRepoName(url); + const safeHref = isSafeHttpUrl(url) ? url : undefined; + + return ( + + + {showIcon && } + {children || {repoName}} + + + ); +}; + +const extractRepoName = (url: string): string => { + try { + const match = url.match(/github\.com\/([^\/]+\/[^\/]+)/); + return match?.[1] ?? url; + } catch { + return url; + } +}; + +const isSafeHttpUrl = (href: string): boolean => { + try { + const u = new URL(href); + return u.protocol === "http:" || u.protocol === "https:"; + } catch { + return false; + } +}; diff --git a/apps/dashboard/hooks/use-mobile.tsx b/apps/dashboard/hooks/use-mobile.tsx index 502fd32393..33920ab763 100644 --- a/apps/dashboard/hooks/use-mobile.tsx +++ b/apps/dashboard/hooks/use-mobile.tsx @@ -1,19 +1,22 @@ import * as React from "react"; -const MOBILE_BREAKPOINT = 768; +type UseIsMobileOptions = { + breakpoint?: number; +}; -export function useIsMobile() { +export function useIsMobile(options: UseIsMobileOptions = {}) { + const { breakpoint = 768 } = options; const [isMobile, setIsMobile] = React.useState(undefined); React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`); const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + setIsMobile(window.innerWidth < breakpoint); }; mql.addEventListener("change", onChange); - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + setIsMobile(window.innerWidth < breakpoint); return () => mql.removeEventListener("change", onChange); - }, []); + }, [breakpoint]); return !!isMobile; } diff --git a/apps/dashboard/lib/trpc/routers/deploy/project/deployment/list.ts b/apps/dashboard/lib/trpc/routers/deploy/project/deployment/list.ts new file mode 100644 index 0000000000..410f1e5973 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/deploy/project/deployment/list.ts @@ -0,0 +1,383 @@ +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/deployment/llm-search/index.ts b/apps/dashboard/lib/trpc/routers/deploy/project/deployment/llm-search/index.ts new file mode 100644 index 0000000000..ce617e35a8 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/deploy/project/deployment/llm-search/index.ts @@ -0,0 +1,20 @@ +import { env } from "@/lib/env"; +import { requireUser, requireWorkspace, t, withLlmAccess } from "@/lib/trpc/trpc"; +import OpenAI from "openai"; +import { z } from "zod"; +import { getStructuredSearchFromLLM } from "./utils"; + +const openai = env().OPENAI_API_KEY + ? new OpenAI({ + apiKey: env().OPENAI_API_KEY, + }) + : null; + +export const deploymentListLlmSearch = t.procedure + .use(requireUser) + .use(requireWorkspace) + .use(withLlmAccess()) + .input(z.object({ query: z.string() })) + .mutation(async ({ ctx }) => { + return await getStructuredSearchFromLLM(openai, ctx.validatedQuery); + }); diff --git a/apps/dashboard/lib/trpc/routers/deploy/project/deployment/llm-search/utils.ts b/apps/dashboard/lib/trpc/routers/deploy/project/deployment/llm-search/utils.ts new file mode 100644 index 0000000000..e63222656e --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/deploy/project/deployment/llm-search/utils.ts @@ -0,0 +1,515 @@ +import { + deploymentListFilterFieldConfig, + deploymentListFilterOutputSchema, +} from "@/app/(app)/projects/[projectId]/deployments/filters.schema"; +import { TRPCError } from "@trpc/server"; +import type OpenAI from "openai"; +import { zodResponseFormat } from "openai/helpers/zod.mjs"; + +export async function getStructuredSearchFromLLM(openai: OpenAI | null, userSearchMsg: string) { + try { + if (!openai) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "OpenAI isn't configured correctly, please check your API key", + }); + } + + const completion = await openai.beta.chat.completions.parse({ + // Don't change the model only a few models allow structured outputs + model: "gpt-4o-mini", + temperature: 0.2, // Range 0-2, lower = more focused/deterministic + top_p: 0.1, // Alternative to temperature, controls randomness + frequency_penalty: 0.5, // Range -2 to 2, higher = less repetition + presence_penalty: 0.5, // Range -2 to 2, higher = more topic diversity + n: 1, // Number of completions to generate + messages: [ + { + role: "system", + content: getSystemPrompt(), + }, + { + role: "user", + content: userSearchMsg, + }, + ], + response_format: zodResponseFormat(deploymentListFilterOutputSchema, "searchQuery"), + }); + + if (!completion.choices[0].message.parsed) { + throw new TRPCError({ + code: "UNPROCESSABLE_CONTENT", + message: + "Try queries like:\n" + + "• 'show failed deployments'\n" + + "• 'production deployments'\n" + + "• 'deployments from main branch'\n" + + "• 'recent deployments'\n" + + "For additional help, contact support@unkey.dev", + }); + } + + return completion.choices[0].message.parsed; + } catch (error) { + console.error( + `Something went wrong when querying OpenAI. Input: ${JSON.stringify( + userSearchMsg, + )}\n Output ${(error as Error).message}`, + ); + + if (error instanceof TRPCError) { + throw error; + } + + if ((error as { response: { status: number } }).response?.status === 429) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: "Search rate limit exceeded. Please try again in a few minutes.", + }); + } + + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "Failed to process your search query. Please try again or contact support@unkey.dev if the issue persists.", + }); + } +} + +export const getSystemPrompt = () => { + const operatorsByField = Object.entries(deploymentListFilterFieldConfig) + .map(([field, config]) => { + const operators = config.operators.join(", "); + return `- ${field} accepts ${operators} operator${config.operators.length > 1 ? "s" : ""}`; + }) + .join("\n"); + + return `You are an expert at converting natural language queries into deployment filters, understanding context and inferring filter types from natural expressions. Handle complex, ambiguous queries by breaking them down into clear filters for deployment management. + +Examples: + +# Status-based Searches +Query: "show failed deployments" +Result: [ + { + field: "status", + filters: [ + { operator: "is", value: "failed" } + ] + } +] + +Query: "show error deployments" +Result: [ + { + field: "status", + filters: [ + { operator: "is", value: "failed" } + ] + } +] + +Query: "find queued and pending deployments" +Result: [ + { + field: "status", + filters: [ + { operator: "is", value: "pending" } + ] + } +] + +Query: "show building deployments" +Result: [ + { + field: "status", + filters: [ + { operator: "is", value: "building" } + ] + } +] + +Query: "in progress deployments" +Result: [ + { + field: "status", + filters: [ + { operator: "is", value: "building" } + ] + } +] + +Query: "show ready or active deployments" +Result: [ + { + field: "status", + filters: [ + { operator: "is", value: "completed" } + ] + } +] + +Query: "find ready or active deployments" +Result: [ + { + field: "status", + filters: [ + { operator: "is", value: "completed" } + ] + } +] + +Query: "ready or active deployments only" +Result: [ + { + field: "status", + filters: [ + { operator: "is", value: "completed" } + ] + } +] + +Query: "show live deployments" +Result: [ + { + field: "status", + filters: [ + { operator: "is", value: "completed" } + ] + } +] + +Query: "successful deployments" +Result: [ + { + field: "status", + filters: [ + { operator: "is", value: "completed" } + ] + } +] + +Query: "success deployments" +Result: [ + { + field: "status", + filters: [ + { operator: "is", value: "completed" } + ] + } +] + +# Environment-based Searches +Query: "production deployments" +Result: [ + { + field: "environment", + filters: [ + { operator: "is", value: "production" } + ] + } +] + +Query: "preview and production deployments" +Result: [ + { + field: "environment", + filters: [ + { operator: "is", value: "preview" }, + { operator: "is", value: "production" } + ] + } +] + +# Branch-based Searches +Query: "deployments from main branch" +Result: [ + { + field: "branch", + filters: [ + { operator: "contains", value: "main" } + ] + } +] + +Query: "feature branch deployments" +Result: [ + { + field: "branch", + filters: [ + { operator: "contains", value: "feature" } + ] + } +] + +Query: "deployments from develop or staging branches" +Result: [ + { + field: "branch", + filters: [ + { operator: "contains", value: "develop" }, + { operator: "contains", value: "staging" } + ] + } +] + +# Time-based Searches +Query: "deployments since yesterday" +Result: [ + { + field: "since", + filters: [ + { operator: "is", value: "yesterday" } + ] + } +] + +Query: "recent deployments" +Result: [ + { + field: "since", + filters: [ + { operator: "is", value: "24h" } + ] + } +] + +# Complex Combinations +Query: "failed production deployments from main branch" +Result: [ + { + field: "status", + filters: [ + { operator: "is", value: "failed" } + ] + }, + { + field: "environment", + filters: [ + { operator: "is", value: "production" } + ] + }, + { + field: "branch", + filters: [ + { operator: "contains", value: "main" } + ] + } +] + +Query: "completed preview deployments from feature branches" +Result: [ + { + field: "status", + filters: [ + { operator: "is", value: "completed" } + ] + }, + { + field: "environment", + filters: [ + { operator: "is", value: "preview" } + ] + }, + { + field: "branch", + filters: [ + { operator: "contains", value: "feature" } + ] + } +] + +Query: "show building or pending deployments in production" +Result: [ + { + field: "status", + filters: [ + { operator: "is", value: "building" }, + { operator: "is", value: "pending" } + ] + }, + { + field: "environment", + filters: [ + { operator: "is", value: "production" } + ] + } +] + +# Status Pattern Recognition +Query: "deployments that are currently running" +Result: [ + { + field: "status", + filters: [ + { operator: "is", value: "building" }, + { operator: "is", value: "pending" } + ] + } +] + +Query: "finished deployments" +Result: [ + { + field: "status", + filters: [ + { operator: "is", value: "completed" }, + { operator: "is", value: "failed" } + ] + } +] + +Query: "live deployments" +Result: [ + { + field: "status", + filters: [ + { operator: "is", value: "completed" } + ] + } +] + +Query: "deployments in progress" +Result: [ + { + field: "status", + filters: [ + { operator: "is", value: "building" } + ] + } +] + +# Branch Pattern Recognition +Query: "hotfix deployments" +Result: [ + { + field: "branch", + filters: [ + { operator: "contains", value: "hotfix" } + ] + } +] + +Query: "release branch deployments" +Result: [ + { + field: "branch", + filters: [ + { operator: "contains", value: "release" } + ] + } +] + +Remember: +${operatorsByField} +- Use exact matches (is) for status and environment fields +- Use contains for branch names to match partial branch patterns +- Time fields (startTime, endTime) use exact matches with numeric values +- Since field uses exact matches with time expressions + +Special handling rules: +1. Map common terms to appropriate fields: + - Status terms ("failed", "completed", "pending", "building") → status field + - Environment terms ("production", "preview", "prod", "staging") → environment field + - Branch patterns ("main", "master", "develop", "feature", "hotfix") → branch field + - Time expressions ("yesterday", "24h", "week", "today") → since field + +2. CRITICAL: Status disambiguation rules: +- "active" or "ready" ALWAYS means completed deployments (live/successful deployments) +- "running" means deployments currently in the deployment process (building + pending) +- "in progress" means deployments currently being deployed (building) +- "live" means completed deployments +- "successful" means completed deployments + +Status aliases and variations: + - "active", "ready", "success", "successful", "done", "live", "finished successfully" → "completed" + - "in progress", "building", "deploying" → "building" + - "pending", "queued", "waiting" → "pending" + - "failed", "error", "broken" → "failed" + - "running", "currently running", "still processing" → "building" and "pending" (deployment process in progress) + - "finished" → "completed" and "failed" (both finished states) + +3. Environment aliases: + - "prod" → "production" + - "staging", "stage" → "preview" + +4. Time expression patterns: + - "recent", "lately" → "24h" + - "today" → "today" + - "yesterday" → "yesterday" + - "this week" → "week" + +Error Handling Rules: +1. Invalid operators: Default to "is" for status/environment, "contains" for branch +2. Empty values: Skip filters with empty or whitespace-only values +3. Invalid status values: Map to closest valid grouped status + +Ambiguity Resolution Priority: +1. Status-based searches when deployment state terms are used +2. Environment-based searches for deployment target terms +3. Branch-based searches for code branch patterns +4. Time-based searches for temporal expressions + +Output Validation: +1. Required fields must be present: field, filters +2. Filters must have: operator, value +3. Values must be non-empty strings +4. Operators must match field configuration +5. Field names must be valid: status, environment, branch, startTime, endTime, since +6. Status values must be one of: pending, building, completed, failed +7. Environment values must be one of: production, preview + +Additional Context: +- Deployments have grouped statuses for filtering: pending, building, completed, failed +- Building status represents multiple internal states: downloading_docker_image, building_rootfs, uploading_rootfs, creating_vm, booting_vm, assigning_domains +- Users see and refer to the grouped statuses: Pending/Queued, Building/In Progress, Ready/Active/Success, Failed/Error +- Backend automatically expands grouped statuses to actual internal statuses for filtering +- Environment is either production or preview +- Branch names can contain various patterns (feature/, hotfix/, release/, etc.) +- Time-based filtering helps find recent or historical deployments +- Only use the grouped status values (pending, building, completed, failed) in filter outputs + +Advanced Examples: + +# Multi-status with Environment +Query: "all failed and completed production deployments" +Result: [ + { + field: "status", + filters: [ + { operator: "is", value: "failed" }, + { operator: "is", value: "completed" } + ] + }, + { + field: "environment", + filters: [ + { operator: "is", value: "production" } + ] + } +] + +# Branch Pattern Matching +Query: "deployments from feature and hotfix branches" +Result: [ + { + field: "branch", + filters: [ + { operator: "contains", value: "feature" }, + { operator: "contains", value: "hotfix" } + ] + } +] + +# Status Category Searches +Query: "show me all ready or active deployments" +Result: [ + { + field: "status", + filters: [ + { operator: "is", value: "pending" }, + { operator: "is", value: "building" } + ] + } +]`; +}; diff --git a/apps/dashboard/lib/trpc/routers/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index bcb2bb6c13..f9d8059bbb 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -38,6 +38,8 @@ import { queryRoles } from "./authorization/roles/query"; import { upsertRole } from "./authorization/roles/upsert"; import { queryUsage } from "./billing/query-usage"; import { createProject } from "./deploy/project/create"; +import { queryDeployments } from "./deploy/project/deployment/list"; +import { deploymentListLlmSearch } from "./deploy/project/deployment/llm-search"; import { queryProjects } from "./deploy/project/list"; import { deploymentRouter } from "./deployment"; import { createIdentity } from "./identity/create"; @@ -312,6 +314,10 @@ export const router = t.router({ list: queryProjects, create: createProject, }), + deployment: t.router({ + list: queryDeployments, + search: deploymentListLlmSearch, + }), }), deployment: deploymentRouter, }); diff --git a/internal/icons/src/icons/circle-dotted.tsx b/internal/icons/src/icons/circle-dotted.tsx new file mode 100644 index 0000000000..bbbb6512e6 --- /dev/null +++ b/internal/icons/src/icons/circle-dotted.tsx @@ -0,0 +1,135 @@ +/** + * Copyright © Nucleo + * Version 1.3, January 3, 2024 + * Nucleo Icons + * https://nucleoapp.com/ + * - Redistribution of icons is prohibited. + * - Icons are restricted for use only within the product they are bundled with. + * + * For more details: + * https://nucleoapp.com/license + */ +import type React from "react"; +import { type IconProps, sizeMap } from "../props"; + +export const CircleDotted: React.FC = ({ size = "xl-thin", ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size]; + return ( + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/internal/icons/src/icons/circle-warning.tsx b/internal/icons/src/icons/circle-warning.tsx new file mode 100644 index 0000000000..1fd5a73243 --- /dev/null +++ b/internal/icons/src/icons/circle-warning.tsx @@ -0,0 +1,42 @@ +/** + * Copyright © Nucleo + * Version 1.3, January 3, 2024 + * Nucleo Icons + * https://nucleoapp.com/ + * - Redistribution of icons is prohibited. + * - Icons are restricted for use only within the product they are bundled with. + * + * For more details: + * https://nucleoapp.com/license + */ +import type React from "react"; +import { type IconProps, sizeMap } from "../props"; + +export const CircleWarning: React.FC = ({ size = "xl-thin", ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size]; + return ( + + + + + + + + ); +}; diff --git a/internal/icons/src/icons/cloud-up.tsx b/internal/icons/src/icons/cloud-up.tsx new file mode 100644 index 0000000000..0851f211f0 --- /dev/null +++ b/internal/icons/src/icons/cloud-up.tsx @@ -0,0 +1,38 @@ +/** + * Copyright © Nucleo + * Version 1.3, January 3, 2024 + * Nucleo Icons + * https://nucleoapp.com/ + * - Redistribution of icons is prohibited. + * - Icons are restricted for use only within the product they are bundled with. + * + * For more details: + * https://nucleoapp.com/license + */ +import type React from "react"; +import { type IconProps, sizeMap } from "../props"; + +export const CloudUp: React.FC = ({ size = "xl-thin", ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size]; + return ( + + + + + + + + ); +}; diff --git a/internal/icons/src/icons/cloud.tsx b/internal/icons/src/icons/cloud.tsx new file mode 100644 index 0000000000..c3d1eee437 --- /dev/null +++ b/internal/icons/src/icons/cloud.tsx @@ -0,0 +1,38 @@ +/** + * Copyright © Nucleo + * Version 1.3, January 3, 2024 + * Nucleo Icons + * https://nucleoapp.com/ + * - Redistribution of icons is prohibited. + * - Icons are restricted for use only within the product they are bundled with. + * + * For more details: + * https://nucleoapp.com/license + */ +import type React from "react"; +import { type IconProps, sizeMap } from "../props"; + +export const Cloud: React.FC = ({ size = "xl-thin", ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size]; + + return ( + + + + + + + ); +}; diff --git a/internal/icons/src/icons/code-commit.tsx b/internal/icons/src/icons/code-commit.tsx new file mode 100644 index 0000000000..0af3a134c0 --- /dev/null +++ b/internal/icons/src/icons/code-commit.tsx @@ -0,0 +1,38 @@ +/** + * Copyright © Nucleo + * Version 1.3, January 3, 2024 + * Nucleo Icons + * https://nucleoapp.com/ + * - Redistribution of icons is prohibited. + * - Icons are restricted for use only within the product they are bundled with. + * + * For more details: + * https://nucleoapp.com/license + */ +import type React from "react"; +import { type IconProps, sizeMap } from "../props"; + +export const CodeCommit: React.FC = ({ size = "xl-thin", ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size]; + + return ( + + + + + + + + ); +}; diff --git a/internal/icons/src/icons/connections.tsx b/internal/icons/src/icons/connections.tsx new file mode 100644 index 0000000000..f4f0331f79 --- /dev/null +++ b/internal/icons/src/icons/connections.tsx @@ -0,0 +1,50 @@ +/** + * Copyright © Nucleo + * Version 1.3, January 3, 2024 + * Nucleo Icons + * https://nucleoapp.com/ + * - Redistribution of icons is prohibited. + * - Icons are restricted for use only within the product they are bundled with. + * + * For more details: + * https://nucleoapp.com/license + */ +import type React from "react"; +import { type IconProps, sizeMap } from "../props"; + +export const Connections: React.FC = ({ size = "xl-thin", ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size]; + + return ( + + + + + + + + ); +}; diff --git a/internal/icons/src/icons/half-dotted-circle-play.tsx b/internal/icons/src/icons/half-dotted-circle-play.tsx new file mode 100644 index 0000000000..7834dd8ab1 --- /dev/null +++ b/internal/icons/src/icons/half-dotted-circle-play.tsx @@ -0,0 +1,86 @@ +/** + * Copyright © Nucleo + * Version 1.3, January 3, 2024 + * Nucleo Icons + * https://nucleoapp.com/ + * - Redistribution of icons is prohibited. + * - Icons are restricted for use only within the product they are bundled with. + * + * For more details: + * https://nucleoapp.com/license + */ +import type React from "react"; +import { type IconProps, sizeMap } from "../props"; + +export const HalfDottedCirclePlay: React.FC = ({ size = "xl-thin", ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size]; + return ( + + + + + + + + + + + + + + ); +}; diff --git a/internal/icons/src/icons/nut.tsx b/internal/icons/src/icons/nut.tsx new file mode 100644 index 0000000000..7bfb2cc6f5 --- /dev/null +++ b/internal/icons/src/icons/nut.tsx @@ -0,0 +1,43 @@ +/** + * Copyright © Nucleo + * Version 1.3, January 3, 2024 + * Nucleo Icons + * https://nucleoapp.com/ + * - Redistribution of icons is prohibited. + * - Icons are restricted for use only within the product they are bundled with. + * + * For more details: + * https://nucleoapp.com/license + */ +import type React from "react"; +import { type IconProps, sizeMap } from "../props"; + +export const Nut: React.FC = ({ size = "xl-thin", ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size]; + + return ( + + + + + + + ); +}; diff --git a/internal/icons/src/index.ts b/internal/icons/src/index.ts index 4eec3403ee..3202ab7248 100644 --- a/internal/icons/src/index.ts +++ b/internal/icons/src/index.ts @@ -36,18 +36,25 @@ export * from "./icons/chevron-up"; export * from "./icons/circle-caret-down"; export * from "./icons/circle-caret-right"; export * from "./icons/circle-check"; +export * from "./icons/circle-dotted"; export * from "./icons/circle-half-dotted-clock"; export * from "./icons/circle-info"; export * from "./icons/circle-info-sparkle"; export * from "./icons/circle-lock"; export * from "./icons/circle-question"; +export * from "./icons/circle-warning"; export * from "./icons/clipboard"; export * from "./icons/clipboard-check"; export * from "./icons/clock"; export * from "./icons/clock-rotate-clockwise"; export * from "./icons/clone"; +export * from "./icons/cloud"; +export * from "./icons/cloud-up"; export * from "./icons/code"; export * from "./icons/code-branch"; +export * from "./icons/code-commit"; +export * from "./icons/coins"; +export * from "./icons/connections"; export * from "./icons/conversion"; export * from "./icons/cube"; export * from "./icons/dots"; @@ -61,6 +68,7 @@ export * from "./icons/gauge"; export * from "./icons/gear"; export * from "./icons/github"; export * from "./icons/grid"; +export * from "./icons/half-dotted-circle-play"; export * from "./icons/input-password-edit"; export * from "./icons/input-password-settings"; export * from "./icons/input-search"; @@ -75,6 +83,7 @@ export * from "./icons/magnifier"; export * from "./icons/moon-stars"; export * from "./icons/nodes"; export * from "./icons/number-input"; +export * from "./icons/nut"; export * from "./icons/page-2"; export * from "./icons/pen-writing-3"; export * from "./icons/plus"; @@ -104,4 +113,3 @@ export * from "./icons/ufo"; export * from "./icons/user-search"; export * from "./icons/user"; export * from "./icons/xmark"; -export * from "./icons/coins"; diff --git a/internal/ui/colors.css b/internal/ui/colors.css index b023600376..07bdc09cf6 100644 --- a/internal/ui/colors.css +++ b/internal/ui/colors.css @@ -190,6 +190,20 @@ https://www.radix-ui.com/colors/docs/palette-composition/scales --info-11: 208, 88%, 43%; --info-12: 216, 71%, 23%; + /* BlueA */ + --infoA-1: 210, 100%, 50%, 0.02; + --infoA-2: 207, 100%, 50%, 0.04; + --infoA-3: 213, 100%, 50%, 0.09; + --infoA-4: 208, 100%, 50%, 0.15; + --infoA-5: 210, 100%, 50%, 0.22; + --infoA-6: 212, 100%, 50%, 0.3; + --infoA-7: 212, 100%, 47%, 0.41; + --infoA-8: 212, 100%, 46%, 0.57; + --infoA-9: 206, 100%, 50%, 1; + --infoA-10: 206, 100%, 46%, 1; + --infoA-11: 209, 100%, 43%, 1; + --infoA-12: 208, 100%, 19%, 1; + /* Slate */ /* Brand */ --accent-1: 240, 20%, 99%; @@ -388,6 +402,20 @@ https://www.radix-ui.com/colors/docs/palette-composition/scales --info-11: 210, 100%, 72%; --info-12: 205, 100%, 88%; + /* BlueA */ + --infoA-1: 231, 100%, 49%, 0.05; + --infoA-2: 215, 100%, 49%, 0.09; + --infoA-3: 212, 100%, 50%, 0.23; + --infoA-4: 213, 100%, 50%, 0.34; + --infoA-5: 210, 100%, 50%, 0.42; + --infoA-6: 209, 98%, 53%, 0.49; + --infoA-7: 210, 100%, 57%, 0.59; + --infoA-8: 210, 99%, 58%, 0.73; + --infoA-9: 206, 100%, 50%, 1; + --infoA-10: 208, 100%, 50%, 0.94; + --infoA-11: 210, 100%, 72%, 1; + --infoA-12: 211, 100%, 89%, 1; + /* Slate Dark */ --accent-1: 240, 6%, 7%; --accent-2: 220, 6%, 10%; diff --git a/internal/ui/tailwind.config.js b/internal/ui/tailwind.config.js index 5d4a7b6e35..324cdad81b 100644 --- a/internal/ui/tailwind.config.js +++ b/internal/ui/tailwind.config.js @@ -48,7 +48,7 @@ export default { const getColor = (colorVar, { opacityVariable, opacityValue }) => { // For alpha colors, we need to extract the alpha from the variable itself // to avoid the syntax error in the generated CSS - const alphaColors = ["grayA", "errorA", "successA", "warningA", "orangeA"]; + const alphaColors = ["grayA", "errorA", "successA", "warningA", "orangeA", "infoA"]; if (alphaColors.some((color) => colorVar.includes(color))) { return `hsla(var(--${colorVar.replace("--", "")}))`; } @@ -67,6 +67,7 @@ function generateRadixColors() { "gray", "grayA", "info", + "infoA", "success", "successA", // Added tealA "orange",