diff --git a/apps/dashboard/app/(app)/projects/page.tsx b/apps/dashboard/app/(app)/projects/page.tsx index 6f4c454c10..3fcf50f45b 100644 --- a/apps/dashboard/app/(app)/projects/page.tsx +++ b/apps/dashboard/app/(app)/projects/page.tsx @@ -1,23 +1,23 @@ +"use server"; import { getAuth } from "@/lib/auth"; import { db } from "@/lib/db"; -import { notFound, redirect } from "next/navigation"; +import { notFound } from "next/navigation"; import { Suspense } from "react"; import { ProjectsClient } from "./projects-client"; -export default async function ProjectsPage(): Promise { +export default async function ProjectsPage() { 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) { + 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 notFound(); } - return ( Loading...}> diff --git a/apps/dashboard/app/(app)/projects/projects-client.tsx b/apps/dashboard/app/(app)/projects/projects-client.tsx index 1c673f43a6..de46336f0b 100644 --- a/apps/dashboard/app/(app)/projects/projects-client.tsx +++ b/apps/dashboard/app/(app)/projects/projects-client.tsx @@ -1,58 +1,81 @@ "use client"; - -import { GitHub } from "@/components/ui/icons"; import { trpc } from "@/lib/trpc/client"; -import { Button } from "@unkey/ui"; -import { - Activity, - ExternalLink, - Eye, - Filter, - FolderOpen, - FolderPlus, - Plus, - Search, - Tag, -} from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; + +function useProjectsQuery() { + const [nameFilter, setNameFilter] = useState(""); + + const queryParams = useMemo( + () => ({ + ...(nameFilter && { + name: [{ operator: "contains" as const, value: nameFilter }], + }), + }), + [nameFilter], + ); + + const { data, hasNextPage, fetchNextPage, isFetchingNextPage, isLoading, refetch } = + trpc.deploy.project.list.useInfiniteQuery(queryParams, { + getNextPageParam: (lastPage) => lastPage.nextCursor, + staleTime: 30000, // 30 seconds + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + const projects = useMemo(() => { + if (!data?.pages) { + return []; + } + return data.pages.flatMap((page) => page.projects); + }, [data]); -// Type definitions -interface Project { - id: string; - name: string; - slug: string; - gitRepositoryUrl: string | null; - createdAt: number; - updatedAt: number | null; + const total = data?.pages[0]?.total ?? 0; + + return { + projects, + isLoading, + hasMore: hasNextPage, + loadMore: fetchNextPage, + isLoadingMore: isFetchingNextPage, + total, + refetch, + // Filter setters + setNameFilter, + nameFilter, + }; } -export function ProjectsClient(): JSX.Element { - const [name, setName] = useState(""); - const [slug, setSlug] = useState(""); - const [gitUrl, setGitUrl] = useState(""); - const [searchTerm, setSearchTerm] = useState(""); - const [showCreateForm, setShowCreateForm] = useState(false); +export function ProjectsClient() { + const [name, setName] = useState(""); + const [slug, setSlug] = useState(""); + const [gitUrl, setGitUrl] = useState(""); + + const { + projects, + isLoading, + hasMore, + loadMore, + isLoadingMore, + total, + refetch, + setNameFilter, + nameFilter, + } = useProjectsQuery(); - // Use actual tRPC hooks - const { data, isLoading, refetch } = trpc.project.list.useQuery(); const createProject = trpc.project.create.useMutation({ onSuccess: () => { refetch(); setName(""); setSlug(""); setGitUrl(""); - setShowCreateForm(false); }, }); - const handleSubmit = async ( - e: React.FormEvent | React.MouseEvent, - ): Promise => { + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!name || !slug) { return; } - createProject.mutate({ name, slug, @@ -60,325 +83,219 @@ export function ProjectsClient(): JSX.Element { }); }; - // Get projects from tRPC data - const projects: Project[] = data?.projects || []; - const isCreating: boolean = createProject.isLoading; - - const filteredProjects: Project[] = projects.filter( - (project: Project) => - project.name.toLowerCase().includes(searchTerm.toLowerCase()) || - project.slug.toLowerCase().includes(searchTerm.toLowerCase()), - ); + const handleLoadMore = () => { + if (hasMore && !isLoadingMore) { + loadMore(); + } + }; - const generateSlug = (name: string): string => { - return name - .toLowerCase() - .replace(/[^a-z0-9\s-]/g, "") - .replace(/\s+/g, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, ""); + const clearFilters = () => { + setNameFilter(""); }; return ( -
-
- {/* Header */} -
-
-
-

Projects

-

- Manage your deployment projects and configurations -

-
- -
-
+
+

Projects

- {/* Search and Filters */} -
-
- - ) => 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" - /> -
- + {/* Filters */} +
+

Filters

+
+ setNameFilter(e.target.value)} + style={{ padding: "5px", flex: 1 }} + />
+ +
- {/* Error Display */} - {createProject.error && ( -
-
-
- - - -
-
-

Error creating project

-
{createProject.error.message}
-
-
-
- )} - - {/* Create Project Form */} - {showCreateForm && ( -
-
-

Create New Project

- -
- -
-
-
- - ) => { - setName(e.target.value); - setSlug(generateSlug(e.target.value)); - }} - placeholder="Enter project name" - className="w-full px-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-brand focus:border-transparent bg-white text-content" - required - /> -
+

Existing Projects ({total} total)

-
- - ) => setSlug(e.target.value)} - placeholder="project-slug" - className="w-full px-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-brand focus:border-transparent bg-white text-content" - required - /> -
-
+ {isLoading ? ( +

Loading...

+ ) : projects.length > 0 ? ( +
+ {projects.map((project) => ( +
+

{project.name}

+

+ Slug: {project.slug} +

+

+ ID: {project.id} +

+ {project.gitRepositoryUrl && ( +

+ Git: {project.gitRepositoryUrl} +

+ )} +

+ Branch: {project.branch || "main"} +

+

+ Created: {new Date(project.createdAt).toLocaleString()} +

+ {project.updatedAt && ( +

+ Updated: {new Date(project.updatedAt).toLocaleString()} +

+ )} -
- -
- - ) => setGitUrl(e.target.value)} - placeholder="https://github.com/username/repository" - 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" - /> + {/* Display hostnames */} + {project.hostnames && project.hostnames.length > 0 && ( +
+ Hostnames: +
-
+ )} -
- - -
+ + View Branches → +
-
- )} + ))} - {/* Projects Grid */} - {isLoading ? ( -
- {Array.from({ length: 8 }, (_, i) => ({ id: `skeleton-${i}` })).map((item) => ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ))} -
- ) : filteredProjects.length > 0 ? ( -
- {filteredProjects.map((project) => ( -
+ +
+ )} +
+ ) : ( +

No projects found.

+ )} - {/* Repository info */} - {project.gitRepositoryUrl && ( -
- - - {project.gitRepositoryUrl.replace("https://github.com/", "")} - -
- )} -
- +

Create New Project

+
+
+ +
+
+ +
+
+ +
+ +
- {/* Footer Actions */} -
-
- - - View Project - - -
-
- Active -
-
-
-
- ))} -
- ) : ( -
-
-
- -
-

- {searchTerm ? "No projects found" : "No projects yet"} -

-

- {searchTerm - ? `No projects match "${searchTerm}". Try adjusting your search criteria.` - : "Create your first project to start deploying APIs globally with predictable performance."} -

- {!searchTerm && ( -
- - - - Documentation - -
- )} -
-
- )} -
+ {createProject.error && ( +

Error: {createProject.error.message}

+ )}
); } diff --git a/apps/dashboard/lib/trpc/routers/deploy/project/filters.schema.ts b/apps/dashboard/lib/trpc/routers/deploy/project/filters.schema.ts new file mode 100644 index 0000000000..db39d85a46 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/deploy/project/filters.schema.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; + +// Filter operators +export const projectsFilterOperatorEnum = z.enum(["contains"]); + +// Filter item schema +const filterItemSchema = z.object({ + operator: projectsFilterOperatorEnum, + value: z.string(), +}); + +const baseFilterArraySchema = z.array(filterItemSchema).nullish(); + +// Query payload schema +export const projectsInputSchema = z.object({ + name: baseFilterArraySchema, + slug: baseFilterArraySchema, + branch: baseFilterArraySchema, + cursor: z.number().int().optional(), +}); + +// Hostname schema +export const hostnameSchema = z.object({ + id: z.string(), + hostname: z.string(), +}); + +// Project response schema +export const projectSchema = 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(hostnameSchema), +}); + +// Projects list response schema +export const projectsResponseSchema = z.object({ + projects: z.array(projectSchema), + hasMore: z.boolean(), + total: z.number(), + nextCursor: z.number().int().nullish(), +}); + +// Exported types +export type ProjectsQueryPayload = z.infer; +export type Project = z.infer; +export type Hostname = z.infer; +export type ProjectsQueryResponse = z.infer; + +// Constants +export const PROJECTS_LIMIT = 10; +export const FILTERABLE_FIELDS = ["name", "slug", "branch"] as const; +export type FilterableField = (typeof FILTERABLE_FIELDS)[number]; diff --git a/apps/dashboard/lib/trpc/routers/deploy/project/list.ts b/apps/dashboard/lib/trpc/routers/deploy/project/list.ts new file mode 100644 index 0000000000..28986f9241 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/deploy/project/list.ts @@ -0,0 +1,186 @@ +import { and, count, db, desc, eq, like, lt, or, schema } from "@/lib/db"; +import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { TRPCError } from "@trpc/server"; +import { PROJECTS_LIMIT, projectsInputSchema, projectsResponseSchema } from "./filters.schema"; + +export const queryProjects = t.procedure + .use(requireUser) + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input(projectsInputSchema) + .output(projectsResponseSchema) + .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") { + baseConditions.push(lt(schema.projects.createdAt, input.cursor)); + } + + // Build filter conditions + const filterConditions = []; + + // Name filter + if (input.name && input.name.length > 0) { + const nameConditions = input.name.map((filter) => { + if (filter.operator === "contains") { + return like(schema.projects.name, `%${filter.value}%`); + } + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Unsupported name operator: ${filter.operator}`, + }); + }); + + if (nameConditions.length === 1) { + filterConditions.push(nameConditions[0]); + } else { + filterConditions.push(or(...nameConditions)); + } + } + + // Slug filter + if (input.slug && input.slug.length > 0) { + const slugConditions = input.slug.map((filter) => { + if (filter.operator === "contains") { + return like(schema.projects.slug, `%${filter.value}%`); + } + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Unsupported slug operator: ${filter.operator}`, + }); + }); + + if (slugConditions.length === 1) { + filterConditions.push(slugConditions[0]); + } else { + filterConditions.push(or(...slugConditions)); + } + } + + // Branch filter (searches defaultBranch) + if (input.branch && input.branch.length > 0) { + const branchConditions = input.branch.map((filter) => { + if (filter.operator === "contains") { + return like(schema.projects.defaultBranch, `%${filter.value}%`); + } + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Unsupported branch operator: ${filter.operator}`, + }); + }); + + if (branchConditions.length === 1) { + filterConditions.push(branchConditions[0]); + } else { + filterConditions.push(or(...branchConditions)); + } + } + + // Combine all conditions + const allConditions = + filterConditions.length > 0 ? [...baseConditions, ...filterConditions] : baseConditions; + + 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.createdAt)], + limit: PROJECTS_LIMIT + 1, // Get one extra to check if there are more + 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 in a separate query + const hostnamesResult = + projectIds.length > 0 + ? await db.query.hostnames.findMany({ + where: and( + eq(schema.hostnames.workspaceId, ctx.workspace.id), + or(...projectIds.map((id) => eq(schema.hostnames.projectId, id))), + ), + columns: { + id: true, + projectId: true, + hostname: true, + isCustomDomain: true, + verificationStatus: true, + subdomainConfig: true, + }, + orderBy: [desc(schema.hostnames.createdAt)], + }) + : []; + + // Group hostnames by projectId + const hostnamesByProject = hostnamesResult.reduce( + (acc, hostname) => { + if (!acc[hostname.projectId]) { + acc[hostname.projectId] = []; + } + acc[hostname.projectId].push({ + id: hostname.id, + hostname: hostname.hostname, + }); + return acc; + }, + {} as Record< + string, + Array<{ + id: string; + hostname: string; + }> + >, + ); + + 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 = { + projects, + hasMore, + total: totalResult[0]?.count ?? 0, + nextCursor: projects.length > 0 ? 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/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index 3e7aa79a8d..cdc300d7a2 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -37,6 +37,7 @@ 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 { queryProjects } from "./deploy/project/list"; import { deploymentRouter } from "./deployment"; import { createIdentity } from "./identity/create"; import { queryIdentities } from "./identity/query"; @@ -307,6 +308,11 @@ export const router = t.router({ search: searchIdentities, }), project: projectRouter, + deploy: t.router({ + project: t.router({ + list: queryProjects, + }), + }), deployment: deploymentRouter, });