diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-search/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-search/index.tsx index 06d5b34780..f4e8f0883b 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-search/index.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/controls/components/deployment-list-search/index.tsx @@ -6,7 +6,7 @@ import { useFilters } from "../../../../hooks/use-filters"; export const DeploymentListSearch = () => { const { filters, updateFilters } = useFilters(); - const queryLLMForStructuredOutput = trpc.deploy.deployment.search.useMutation({ + const queryLLMForStructuredOutput = trpc.deploy.project.deployment.search.useMutation({ onSuccess(data) { if (data?.filters.length === 0 || !data) { toast.error( diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/hooks/use-deployments-list-query.ts b/apps/dashboard/app/(app)/projects/[projectId]/deployments/components/table/hooks/use-deployments-list-query.ts index 87f900b58f..e78ebbeb24 100644 --- 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 @@ -90,7 +90,7 @@ export function useDeploymentsListQuery() { fetchNextPage, isFetchingNextPage, isLoading: isLoadingInitial, - } = trpc.deploy.deployment.list.useInfiniteQuery(queryParams, { + } = trpc.deploy.project.deployment.list.useInfiniteQuery(queryParams, { getNextPageParam: (lastPage) => lastPage.nextCursor, staleTime: 30_000, refetchOnMount: false, diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/page.tsx b/apps/dashboard/app/(app)/projects/[projectId]/deployments/page.tsx new file mode 100644 index 0000000000..a81f5c97be --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/deployments/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { DeploymentsListControlCloud } from "./components/control-cloud"; +import { DeploymentsListControls } from "./components/controls"; +import { DeploymentsList } from "./components/table/deployments-list"; + +export default function Deployments() { + return ( +
+ + + +
+ ); +} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/card.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/card.tsx new file mode 100644 index 0000000000..68bf8d9380 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/card.tsx @@ -0,0 +1,12 @@ +import { cn } from "@unkey/ui/src/lib/utils"; +import type { ReactNode } from "react"; + +export function Card({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return
{children}
; +} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/filter-button.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/filter-button.tsx new file mode 100644 index 0000000000..3810701fa5 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/filter-button.tsx @@ -0,0 +1,34 @@ +import type { IconProps } from "@unkey/icons/src/props"; +import { Button } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; + +type FilterButtonProps = { + isActive: boolean; + count: number; + onClick: () => void; + icon: React.ComponentType; + label: string; +}; + +export const FilterButton = ({ + isActive, + count, + onClick, + icon: Icon, + label, +}: FilterButtonProps) => ( + +); diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/hooks/use-deployment-logs.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/hooks/use-deployment-logs.tsx new file mode 100644 index 0000000000..479379dcf2 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/hooks/use-deployment-logs.tsx @@ -0,0 +1,162 @@ +import { trpc } from "@/lib/trpc/client"; +import { format } from "date-fns"; +import { useEffect, useMemo, useRef, useState } from "react"; + +type LogEntry = { + timestamp: string; + level?: "info" | "warning" | "error"; + message: string; +}; + +type LogFilter = "all" | "errors" | "warnings"; + +type UseDeploymentLogsProps = { + deploymentId: string; +}; + +type UseDeploymentLogsReturn = { + // State + logFilter: LogFilter; + searchTerm: string; + isExpanded: boolean; + showFade: boolean; + // Computed + filteredLogs: LogEntry[]; + logCounts: { + total: number; + errors: number; + warnings: number; + }; + // Loading state + isLoading: boolean; + // Actions + setLogFilter: (filter: LogFilter) => void; + setSearchTerm: (term: string) => void; + setExpanded: (expanded: boolean) => void; + handleScroll: (e: React.UIEvent) => void; + handleFilterChange: (filter: LogFilter) => void; + handleSearchChange: (e: React.ChangeEvent) => void; + // Refs + scrollRef: React.RefObject; +}; + +export function useDeploymentLogs({ + deploymentId, +}: UseDeploymentLogsProps): UseDeploymentLogsReturn { + const [logFilter, setLogFilter] = useState("all"); + const [searchTerm, setSearchTerm] = useState(""); + const [isExpanded, setIsExpanded] = useState(false); + const [showFade, setShowFade] = useState(true); + const scrollRef = useRef(null); + + // Fetch logs via tRPC + const { data: logsData, isLoading } = trpc.deploy.project.activeDeployment.buildLogs.useQuery({ + deploymentId, + }); + + // Transform tRPC logs to match the expected format + const logs = useMemo((): LogEntry[] => { + if (!logsData?.logs) { + return []; + } + + return logsData.logs.map((log) => ({ + timestamp: format(new Date(log.timestamp), "HH:mm:ss.SSS"), + level: log.level, + message: log.message, + })); + }, [logsData]); + + // Auto-expand when logs are fetched + useEffect(() => { + if (logsData?.logs && logsData.logs.length > 0) { + setIsExpanded(true); + } + }, [logsData]); + + // Calculate log counts + const logCounts = useMemo( + () => ({ + total: logs.length, + errors: logs.filter((log) => log.level === "error").length, + warnings: logs.filter((log) => log.level === "warning").length, + }), + [logs], + ); + + // Filter logs by level and search term + const filteredLogs = useMemo(() => { + let filtered = logs; + + // Apply level filter + if (logFilter === "errors") { + filtered = logs.filter((log) => log.level === "error"); + } else if (logFilter === "warnings") { + filtered = logs.filter((log) => log.level === "warning"); + } + + // Apply search filter + if (searchTerm.trim()) { + filtered = filtered.filter( + (log) => + log.message.toLowerCase().includes(searchTerm.toLowerCase()) || + log.timestamp.includes(searchTerm) || + log.level?.toLowerCase().includes(searchTerm.toLowerCase()), + ); + } + + return filtered; + }, [logs, logFilter, searchTerm]); + + const resetScroll = () => { + if (scrollRef.current) { + scrollRef.current.scrollTop = 0; + setShowFade(true); + } + }; + + const setExpanded = (expanded: boolean) => { + setIsExpanded(expanded); + if (!expanded) { + setTimeout(resetScroll, 50); + } + }; + + const handleScroll = (e: React.UIEvent) => { + const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; + const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1; + setShowFade(!isAtBottom); + }; + + const handleFilterChange = (filter: LogFilter) => { + setLogFilter(filter); + resetScroll(); + }; + + const handleSearchChange = (e: React.ChangeEvent) => { + setSearchTerm(e.target.value); + resetScroll(); + }; + + return { + // State + logFilter, + searchTerm, + isExpanded, + showFade, + // Computed + filteredLogs, + logCounts, + // Loading state + isLoading, + // Actions + setLogFilter, + setSearchTerm, + setExpanded, + handleScroll, + handleFilterChange, + handleSearchChange, + // Refs + scrollRef, + }; +} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/index.tsx new file mode 100644 index 0000000000..121aacd233 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/index.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { trpc } from "@/lib/trpc/client"; +import { + ChevronDown, + CircleCheck, + CircleWarning, + CircleXMark, + CodeBranch, + CodeCommit, + FolderCloud, + Layers3, + Magnifier, + TriangleWarning2, +} from "@unkey/icons"; +import { Badge, Button, Card, CopyButton, Input, TimestampInfo } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { useProjectLayout } from "../../layout-provider"; +import { FilterButton } from "./filter-button"; +import { useDeploymentLogs } from "./hooks/use-deployment-logs"; +import { InfoChip } from "./info-chip"; +import { ActiveDeploymentCardSkeleton } from "./skeleton"; +import { StatusIndicator } from "./status-indicator"; + +const ANIMATION_STYLES = { + expand: "transition-all duration-400 ease-in", + slideIn: "transition-all duration-500 ease-out", +} as const; + +export const STATUS_CONFIG = { + success: { variant: "success" as const, icon: CircleCheck, text: "Active" }, + failed: { variant: "error" as const, icon: CircleWarning, text: "Error" }, + pending: { + variant: "warning" as const, + icon: CircleWarning, + text: "Pending", + }, +} as const; + +export function ActiveDeploymentCard() { + const { activeDeploymentId } = useProjectLayout(); + + // Get the cached deployment details + const trpcUtil = trpc.useUtils(); + const deploymentDetails = trpcUtil.deploy.project.activeDeployment.details.getData({ + deploymentId: activeDeploymentId, + }); + + const { + logFilter, + searchTerm, + isExpanded, + showFade, + filteredLogs, + logCounts, + setExpanded, + handleScroll, + handleFilterChange, + handleSearchChange, + scrollRef, + } = useDeploymentLogs({ deploymentId: activeDeploymentId }); + + if (!deploymentDetails) { + return ; + } + + const statusConfig = STATUS_CONFIG[deploymentDetails.buildStatus]; + const [imageName, imageTag] = deploymentDetails.image.split(":"); + + return ( + +
+
+ +
+
v_alpha001
+
{deploymentDetails.description}
+
+
+
+ +
+ + {statusConfig.text} +
+
+
+
+ Created by + {deploymentDetails.author.name} + + {deploymentDetails.author.name} + +
+
+
+
+ +
+
+
+
+ +
+
+ +
+ + {deploymentDetails.branch} + + + {deploymentDetails.commit} + +
+ using image + +
+ {imageName}:{imageTag} +
+
+
+
+
Build logs
+ +
+
+ + {/* Expandable Logs Section */} +
+
+ handleFilterChange("all")} + icon={Layers3} + label="All Logs" + /> + handleFilterChange("errors")} + icon={CircleXMark} + label="Errors" + /> + handleFilterChange("warnings")} + icon={TriangleWarning2} + label="Warnings" + /> + + } + placeholder="Find in logs..." + value={searchTerm} + onChange={handleSearchChange} + /> + + +
+ +
+
+ {filteredLogs.length === 0 ? ( +
+ {searchTerm + ? `No logs match "${searchTerm}"` + : `No ${logFilter === "all" ? "build" : logFilter} logs available`} +
+ ) : ( +
+ {filteredLogs.map((log, index) => ( +
+ {log.timestamp} + {log.level && ( + [{log.level.toUpperCase()}] + )} + {log.message} +
+ ))} +
+ )} +
+
+ + {/* Fade overlay */} + {showFade && ( +
+ )} +
+
+ + ); +} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/info-chip.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/info-chip.tsx new file mode 100644 index 0000000000..0cd4d76169 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/info-chip.tsx @@ -0,0 +1,13 @@ +import type { IconProps } from "@unkey/icons/src/props"; + +type InfoChipProps = { + icon: React.ComponentType; + children: React.ReactNode; +}; + +export const InfoChip = ({ icon: Icon, children }: InfoChipProps) => ( +
+ + {children} +
+); diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/skeleton.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/skeleton.tsx new file mode 100644 index 0000000000..67db5b49b0 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/skeleton.tsx @@ -0,0 +1,67 @@ +import { ChevronDown, CodeBranch, CodeCommit, FolderCloud } from "@unkey/icons"; +import { Badge, Button, Card } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { STATUS_CONFIG } from "."; +import { StatusIndicator } from "./status-indicator"; + +export const ActiveDeploymentCardSkeleton = () => ( + +
+
+ +
+
+
+
+
+
+ +
+ {} + {STATUS_CONFIG.success.text} +
+
+
+
+ Created by +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+ +
+
+
+ +
+
+
+
using image
+
+ +
+
+
+
+
Build logs
+ +
+
+
+ +); diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/status-indicator.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/status-indicator.tsx new file mode 100644 index 0000000000..6c16f8e4be --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/active-deployment-card/status-indicator.tsx @@ -0,0 +1,32 @@ +import { Cloud } from "@unkey/icons"; +import { cn } from "@unkey/ui/src/lib/utils"; + +export function StatusIndicator() { + return ( +
+
+ +
+
+ {[0, 0.15, 0.3, 0.45].map((delay, index) => ( +
+ key={index} + className={cn( + "absolute inset-0 size-2 rounded-full", + index === 0 && "bg-successA-9 opacity-75", + index === 1 && "bg-successA-10 opacity-60", + index === 2 && "bg-successA-11 opacity-40", + index === 3 && "bg-successA-12 opacity-25", + )} + style={{ + animation: "ping 2s cubic-bezier(0, 0, 0.2, 1) infinite", + animationDelay: `${delay}s`, + }} + /> + ))} +
+
+
+ ); +} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/collapsible-row.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/collapsible-row.tsx new file mode 100644 index 0000000000..fc103e6e7e --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/collapsible-row.tsx @@ -0,0 +1,22 @@ +import { ChevronDown } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import type { ReactNode } from "react"; + +type CollapsibleRowProps = { + icon: ReactNode; + title: string; + onToggle?: () => void; +}; +export function CollapsibleRow({ icon, title, onToggle }: CollapsibleRowProps) { + return ( +
+
+ {icon} +
{title}
+
+ +
+ ); +} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/domain-row.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/domain-row.tsx new file mode 100644 index 0000000000..efda47f424 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/domain-row.tsx @@ -0,0 +1,49 @@ +import { CircleCheck, CircleWarning, Link4, ShareUpRight } from "@unkey/icons"; +import { Badge } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; + +type DomainRowProps = { + domain: string; + status: "success" | "error"; + tags: string[]; +}; + +export function DomainRow({ domain, status, tags }: DomainRowProps) { + const statusConfig = { + success: { variant: "success" as const, icon: CircleCheck }, + error: { variant: "error" as const, icon: CircleWarning }, + }; + + const { variant, icon: StatusIcon } = statusConfig[status]; + + return ( +
+
+ +
{domain}
+ +
+
+ {tags.map((tag) => ( + + ))} +
+
+ + + +
+ ); +} + +function InfoTag({ label, className }: { label: string; className?: string }) { + return ( +
{label}
+ ); +} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/add-env-var-row.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/add-env-var-row.tsx new file mode 100644 index 0000000000..4a47fe1467 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/add-env-var-row.tsx @@ -0,0 +1,71 @@ +import { Switch } from "@/components/ui/switch"; +import { Button, Input } from "@unkey/ui"; + +type AddEnvVarRowProps = { + value: { key: string; value: string; isSecret: boolean }; + onChange: (value: { key: string; value: string; isSecret: boolean }) => void; + onSave: () => void; + onCancel: () => void; +}; + +export function AddEnvVarRow({ value, onChange, onSave, onCancel }: AddEnvVarRowProps) { + const handleSave = () => { + if (!value.key.trim() || !value.value.trim()) { + return; + } + onSave(); + }; + + return ( +
+
+ onChange({ ...value, key: e.target.value })} + placeholder="Variable name" + className="min-h-[32px] text-xs w-48" + autoFocus + /> + = + onChange({ ...value, value: e.target.value })} + placeholder="Variable value" + className="min-h-[32px] text-xs flex-1" + type={value.isSecret ? "password" : "text"} + /> +
+
+
+ Secret + onChange({ ...value, isSecret: checked })} + /> +
+ + +
+
+ ); +} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/env-var-row.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/env-var-row.tsx new file mode 100644 index 0000000000..f33026ef41 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/env-var-row.tsx @@ -0,0 +1,120 @@ +import type { EnvVar } from "@/lib/trpc/routers/deploy/project/envs/list"; +import { Eye, EyeSlash, PenWriting3, Trash } from "@unkey/icons"; +import { Button, Input } from "@unkey/ui"; +import { useEffect, useState } from "react"; + +type EnvVarRowProps = { + envVar: EnvVar; + isEditing: boolean; + onEdit: () => void; + onSave: (updates: Partial) => void; + onDelete: () => void; + onCancel: () => void; +}; + +export function EnvVarRow({ + envVar, + isEditing, + onEdit, + onSave, + onDelete, + onCancel, +}: EnvVarRowProps) { + const [editKey, setEditKey] = useState(envVar.key); + const [editValue, setEditValue] = useState(envVar.value); + const [isValueVisible, setIsValueVisible] = useState(false); + + // Make value visible when entering edit mode + useEffect(() => { + if (isEditing) { + setIsValueVisible(true); + } + }, [isEditing]); + + const handleSave = () => { + if (!editKey.trim() || !editValue.trim()) { + return; + } + onSave({ key: editKey.trim(), value: editValue.trim() }); + }; + + const handleCancel = () => { + setEditKey(envVar.key); + setEditValue(envVar.value); + setIsValueVisible(false); + onCancel(); + }; + + if (isEditing) { + return ( +
+
+ setEditKey(e.target.value)} + placeholder="Variable name" + className="min-h-[32px] text-xs w-48 " + autoFocus + /> + = + setEditValue(e.target.value)} + placeholder="Variable value" + className="min-h-[32px] text-xs flex-1" + type="text" + /> +
+
+ + +
+
+ ); + } + + return ( +
+
+
{envVar.key}
+ = +
+
+ {envVar.isSecret && !isValueVisible ? "••••••••••••••••" : envVar.value} +
+ {envVar.isSecret && ( + + )} +
+
+
+ + +
+
+ ); +} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/hooks/use-env-var.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/hooks/use-env-var.tsx new file mode 100644 index 0000000000..bf3a94863f --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/hooks/use-env-var.tsx @@ -0,0 +1,91 @@ +import { trpc } from "@/lib/trpc/client"; +import type { EnvVar } from "@/lib/trpc/routers/deploy/project/envs/list"; +import { useCallback, useEffect, useState } from "react"; + +type UseEnvVarsProps = { + projectId: string; + environment: "production" | "preview" | "development"; +}; + +export function useEnvVars({ environment, projectId }: UseEnvVarsProps) { + const [editingId, setEditingId] = useState(null); + const [newVar, setNewVar] = useState({ key: "", value: "", isSecret: false }); + const [isAddingNew, setIsAddingNew] = useState(false); + const trpcUtil = trpc.useUtils(); + + const allEnvVars = trpcUtil.deploy.project.envs.getEnvs.getData({ + projectId, + }); + + const envVars = allEnvVars?.[environment] || []; + const [localEnvVars, setLocalEnvVars] = useState([]); + + // Sync server data with local state when it changes + useEffect(() => { + if (envVars.length > 0) { + setLocalEnvVars(envVars); + } + }, [envVars]); + + const addVariable = useCallback(() => { + if (!newVar.key.trim() || !newVar.value.trim()) { + return; + } + + const newEnvVar: EnvVar = { + id: Date.now().toString(), + key: newVar.key.trim(), + value: newVar.value.trim(), + isSecret: newVar.isSecret, + }; + + setLocalEnvVars((prev) => [...prev, newEnvVar]); + setNewVar({ key: "", value: "", isSecret: false }); + setIsAddingNew(false); + + // TODO: Call create mutation when available + }, [newVar]); + + const updateVariable = useCallback((id: string, updates: Partial) => { + setLocalEnvVars((prev) => prev.map((env) => (env.id === id ? { ...env, ...updates } : env))); + setEditingId(null); + + // TODO: Call update mutation when available + }, []); + + const deleteVariable = useCallback((id: string) => { + setLocalEnvVars((prev) => prev.filter((env) => env.id !== id)); + + // TODO: Call delete mutation when available + }, []); + + const startEditing = useCallback((id: string) => { + setEditingId(id); + setIsAddingNew(false); + }, []); + + const cancelEditing = useCallback(() => { + setEditingId(null); + setIsAddingNew(false); + setNewVar({ key: "", value: "", isSecret: false }); + }, []); + + const startAdding = useCallback(() => { + setIsAddingNew(true); + setEditingId(null); + }, []); + + return { + envVars: localEnvVars, + editingId, + newVar, + isAddingNew, + addVariable, + updateVariable, + deleteVariable, + startEditing, + cancelEditing, + startAdding, + setNewVar, + }; +} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/index.tsx new file mode 100644 index 0000000000..7b39b23782 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/env-variables-section/index.tsx @@ -0,0 +1,168 @@ +import { cn } from "@/lib/utils"; +import { ChevronDown, Plus } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { type ReactNode, useState } from "react"; +import { AddEnvVarRow } from "./add-env-var-row"; +import { EnvVarRow } from "./env-var-row"; +import { useEnvVars } from "./hooks/use-env-var"; + +type EnvironmentVariablesSectionProps = { + icon: ReactNode; + title: string; + projectId: string; + environment: "production" | "preview" | "development"; +}; + +const ANIMATION_STYLES = { + expand: "transition-all duration-400 ease-in", + slideIn: "transition-all duration-300 ease-out", +} as const; + +export function EnvironmentVariablesSection({ + icon, + projectId, + environment, + title, +}: EnvironmentVariablesSectionProps) { + const { + envVars, + editingId, + newVar, + isAddingNew, + addVariable, + updateVariable, + deleteVariable, + startEditing, + cancelEditing, + startAdding, + setNewVar, + } = useEnvVars({ projectId, environment }); + const [isExpanded, setIsExpanded] = useState(false); + + const toggleExpanded = () => { + setIsExpanded(!isExpanded); + if (!isExpanded) { + // Cancel any ongoing editing when collapsing + cancelEditing(); + } + }; + + const showPlusButton = isExpanded && !editingId && !isAddingNew; + + return ( +
+ {/* Header */} + +
+
+ {icon} +
+ {title} {envVars.length > 0 ? `(${envVars.length})` : null}{" "} +
+
+
+ + +
+
+ + {/* Concave Separator - render with animation */} +
+
+
+
+ + {/* Expandable Content */} +
+
+
+ {envVars.length === 0 && !isAddingNew ? ( +
+ No environment variables configured +
+ ) : ( +
+ {envVars.map((envVar, index) => ( +
+ startEditing(envVar.id)} + onSave={(updates) => updateVariable(envVar.id, updates)} + onDelete={() => deleteVariable(envVar.id)} + onCancel={cancelEditing} + /> +
+ ))} + {isAddingNew && ( +
+ +
+ )} +
+ )} +
+
+
+
+
+ ); +} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/project-details-expandables/detail-section.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/project-details-expandables/detail-section.tsx new file mode 100644 index 0000000000..a358894a2e --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/project-details-expandables/detail-section.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from "react"; +import type { DetailItem } from "./sections"; + +type DetailRowProps = { + icon: ReactNode; + label: string; + children: ReactNode; + alignment?: "center" | "start"; +}; + +function DetailRow({ icon, label, children, alignment = "center" }: DetailRowProps) { + const alignmentClass = alignment === "start" ? "items-start" : "items-center"; + + return ( +
+
+
+ {icon} +
+ {label} +
+
{children}
+
+ ); +} + +type DetailSectionProps = { + title: string; + items: DetailItem[]; + isFirst?: boolean; +}; + +export function DetailSection({ title, items, isFirst = false }: DetailSectionProps) { + return ( +
+
+
{title}
+
+
+
+
+ {items.map((item, index) => ( + + {item.content} + + ))} +
+
+ ); +} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/project-details-expandables/index.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/project-details-expandables/index.tsx new file mode 100644 index 0000000000..28c780d23c --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/project-details-expandables/index.tsx @@ -0,0 +1,157 @@ +import { trpc } from "@/lib/trpc/client"; +import { Book2, Cube, DoubleChevronRight } from "@unkey/icons"; +import { Button, InfoTooltip } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { DetailSection } from "./detail-section"; +import { createDetailSections } from "./sections"; + +type ProjectDetailsExpandableProps = { + tableDistanceToTop: number; + isOpen: boolean; + onClose: () => void; + activeDeploymentId: string; +}; + +export const ProjectDetailsExpandable = ({ + tableDistanceToTop, + isOpen, + onClose, + activeDeploymentId, +}: ProjectDetailsExpandableProps) => { + const trpcUtil = trpc.useUtils(); + const details = trpcUtil.deploy.project.activeDeployment.details.getData({ + deploymentId: activeDeploymentId, + }); + + // Shouldn't happen, because layout handles this case + if (!details) { + return null; + } + + const detailSections = createDetailSections(details); + + return ( +
+
+ {/* Scrollable content container */} +
+ {/* Details Header */} +
+
+ + Details +
+ + + +
+ + {/* Animated content with stagger effect */} +
+ {/* Domains Section */} +
+
+ +
+ dashboard +
+ + api.gateway.com + + + {["staging.gateway.com", "dev.gateway.com"].map((region) => ( +
+ + ))} +
+ } + > +
+ +2 +
+
+
+
+
+
+ + {detailSections.map((section, index) => ( +
+ +
+ ))} +
+
+
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/project-details-expandables/sections.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/project-details-expandables/sections.tsx new file mode 100644 index 0000000000..f677937a53 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/details/project-details-expandables/sections.tsx @@ -0,0 +1,271 @@ +import type { DeploymentDetails } from "@/lib/trpc/routers/deploy/project/active-deployment/getDetails"; +import { + Bolt, + ChartActivity, + CircleHalfDottedClock, + CodeBranch, + CodeCommit, + Connections, + FolderCloud, + Gear, + Github, + Grid, + Harddrive, + Heart, + Location2, + MessageWriting, + PaperClip2, + User, +} from "@unkey/icons"; +import { Badge, TimestampInfo } from "@unkey/ui"; +import type { ReactNode } from "react"; + +export type DetailItem = { + icon: ReactNode; + label: string; + content: ReactNode; + alignment?: "center" | "start"; +}; + +export type DetailSection = { + title: string; + items: DetailItem[]; +}; + +export const createDetailSections = (details: DeploymentDetails): DetailSection[] => [ + { + title: "Active deployment", + items: [ + { + icon: , + label: "Repository", + content: ( +
+ {details.repository.owner}/ + {details.repository.name} +
+ ), + }, + { + icon: , + label: "Branch", + content: {details.branch}, + }, + { + icon: , + label: "Commit", + content: {details.commit}, + }, + { + icon: , + label: "Description", + content: ( +
+ {details.description} +
+ ), + }, + { + icon: , + label: "Image", + content: ( +
+ {details.image} +
+ ), + }, + { + icon: , + label: "Author", + content: ( +
+ {details.author.name} + {details.author.name} +
+ ), + }, + { + icon: , + label: "Created", + content: ( + + ), + }, + ], + }, + { + title: "Runtime settings", + items: [ + { + icon: , + label: "Instances", + content: ( +
+ {details.instances} + vm +
+ ), + }, + { + icon: , + label: "Regions", + alignment: "start", + content: ( +
+ {details.regions.map((region) => ( + + {region} + + ))} +
+ ), + }, + { + icon: , + label: "CPU", + content: ( +
+ {details.cpu}vCPUs +
+ ), + }, + { + icon: , + label: "Memory", + content: ( +
+ {details.memory}mb +
+ ), + }, + { + icon: , + label: "Storage", + content: ( +
+ {details.storage} + mb +
+ ), + }, + { + icon: , + label: "Healthcheck", + alignment: "start", + content: ( +
+
+ + {details.healthcheck.method} + +
+ / + + {details.healthcheck.path.replace(/^\/+/, "")} + +
+
+
+
every
+
+ {details.healthcheck.interval}s +
+
+
+ ), + }, + { + icon: , + label: "Scaling", + alignment: "start", + content: ( +
+
+ {details.scaling.min} to{" "} + {details.scaling.max} instances +
+
+ at {details.scaling.threshold}% CPU + threshold +
+
+ ), + }, + ], + }, + { + title: "Build Info", + items: [ + { + icon: , + label: "Image size", + content: ( +
+ {details.imageSize} + mb +
+ ), + }, + { + icon: , + label: "Build time", + content: ( +
+ {details.buildTime}s +
+ ), + }, + { + icon: , + label: "Build status", + content: ( + + {details.buildStatus === "success" + ? "Success" + : details.buildStatus === "failed" + ? "Failed" + : "Pending"} + + ), + }, + { + icon: , + label: "Base image", + content: ( +
+ {details.baseImage} +
+ ), + }, + { + icon: , + label: "Built At", + content: ( + + ), + }, + ], + }, +]; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/details/section.tsx b/apps/dashboard/app/(app)/projects/[projectId]/details/section.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/dashboard/app/(app)/projects/[projectId]/layout-provider.tsx b/apps/dashboard/app/(app)/projects/[projectId]/layout-provider.tsx new file mode 100644 index 0000000000..b9beffc551 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/layout-provider.tsx @@ -0,0 +1,22 @@ +import { createContext, useContext } from "react"; + +type ProjectLayoutContextType = { + isDetailsOpen: boolean; + setIsDetailsOpen: (open: boolean) => void; + + // Active deployment ID for the production environment. + // Must be fetched on the project list screen and passed down to this component. + // Required by ActiveDeploymentCard and ProjectDetailsExpandable components. + activeDeploymentId: string; + projectId: string; +}; + +export const ProjectLayoutContext = createContext(null); + +export const useProjectLayout = () => { + const context = useContext(ProjectLayoutContext); + if (!context) { + throw new Error("useProjectLayout must be used within ProjectLayoutWrapper"); + } + return context; +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/layout.tsx b/apps/dashboard/app/(app)/projects/[projectId]/layout.tsx new file mode 100644 index 0000000000..ebeb05337e --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/layout.tsx @@ -0,0 +1,108 @@ +"use client"; +import { trpc } from "@/lib/trpc/client"; +import { DoubleChevronLeft } from "@unkey/icons"; +import { Button, InfoTooltip } from "@unkey/ui"; +import { useCallback, useEffect, useState } from "react"; +import { ProjectDetailsExpandable } from "./details/project-details-expandables"; +import { ProjectLayoutContext } from "./layout-provider"; +import { ProjectNavigation } from "./navigations/project-navigation"; +import { ProjectSubNavigation } from "./navigations/project-sub-navigation"; + +export default function ProjectLayoutWrapper({ + children, + params: { projectId }, +}: { + children: React.ReactNode; + params: { projectId: string }; +}) { + return {children}; +} + +type ProjectLayoutProps = { + projectId: string; + children: React.ReactNode; +}; + +const FAKE_DEPLOYMENT_ID = "im-a-fake-deployment-id"; +const ProjectLayout = ({ projectId, children }: ProjectLayoutProps) => { + const trpcUtil = trpc.useUtils(); + const [tableDistanceToTop, setTableDistanceToTop] = useState(0); + const [isDetailsOpen, setIsDetailsOpen] = useState(false); + + useEffect(() => { + trpcUtil.deploy.project.envs.getEnvs.prefetch({ + projectId, + }); + }, [trpcUtil, projectId]); + + // This will be called on mount to determine the offset to top, then it will prefetch project details and mount project details drawer. + const handleDistanceToTop = useCallback( + async (distanceToTop: number) => { + setTableDistanceToTop(distanceToTop); + + if (distanceToTop !== 0) { + try { + // Only proceed if prefetch succeeds + await trpcUtil.deploy.project.activeDeployment.details.prefetch({ + deploymentId: FAKE_DEPLOYMENT_ID, + }); + + setTimeout(() => { + setIsDetailsOpen(true); + }, 200); + } catch (error) { + console.error("Failed to prefetch project details:", error); + // Don't open the drawer if prefetch fails + } + } + }, + [trpcUtil], + ); + + return ( + +
+ +
+ + + + } + /> +
+
+
{children}
+ setIsDetailsOpen(false)} + activeDeploymentId={FAKE_DEPLOYMENT_ID} + /> +
+
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/logs/page.tsx b/apps/dashboard/app/(app)/projects/[projectId]/logs/page.tsx new file mode 100644 index 0000000000..60558a57b4 --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/logs/page.tsx @@ -0,0 +1,5 @@ +"use client"; + +export default function ProjectLogs() { + return
Overview
; +} diff --git a/apps/dashboard/app/(app)/projects/[projectId]/deployments/navigation.tsx b/apps/dashboard/app/(app)/projects/[projectId]/navigations/project-navigation.tsx similarity index 58% rename from apps/dashboard/app/(app)/projects/[projectId]/deployments/navigation.tsx rename to apps/dashboard/app/(app)/projects/[projectId]/navigations/project-navigation.tsx index be821d8cd7..e7d6f95c08 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/deployments/navigation.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/navigations/project-navigation.tsx @@ -1,15 +1,17 @@ "use client"; import { QuickNavPopover } from "@/components/navbar-popover"; +import { NavbarActionButton } from "@/components/navigation/action-button"; import { Navbar } from "@/components/navigation/navbar"; import { trpc } from "@/lib/trpc/client"; -import { Cube, Refresh3 } from "@unkey/icons"; +import { ArrowDottedRotateAnticlockwise, Cube, Dots, ListRadio, Refresh3 } from "@unkey/icons"; +import { Button, Separator } from "@unkey/ui"; import { RepoDisplay } from "../../_components/list/repo-display"; -type DeploymentsNavigationProps = { +type ProjectNavigationProps = { projectId: string; }; -export const DeploymentsNavigation = ({ projectId }: DeploymentsNavigationProps) => { +export const ProjectNavigation = ({ projectId }: ProjectNavigationProps) => { const { data: projectData, isLoading } = trpc.deploy.project.list.useInfiniteQuery( {}, // No filters needed { @@ -64,16 +66,31 @@ export const DeploymentsNavigation = ({ projectId }: DeploymentsNavigationProps) - {activeProject.gitRepositoryUrl && ( -
- - Auto-deploys from pushes to - +
+ {activeProject.gitRepositoryUrl && ( +
+ + Auto-deploys from pushes to + +
+ )} + +
+ Visit Project URL + + +
- )} +
); }; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/navigations/project-sub-navigation.tsx b/apps/dashboard/app/(app)/projects/[projectId]/navigations/project-sub-navigation.tsx new file mode 100644 index 0000000000..a3cd400f0e --- /dev/null +++ b/apps/dashboard/app/(app)/projects/[projectId]/navigations/project-sub-navigation.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { Cloud, GridCircle, Layers3 } from "@unkey/icons"; +import type { IconProps } from "@unkey/icons/src/props"; +import { useParams, usePathname, useRouter } from "next/navigation"; +import { useEffect, useRef } from "react"; + +type TabItem = { + id: string; + label: string; + icon?: React.ComponentType; + path: string; +}; + +export const ProjectSubNavigation = ({ + onMount, + detailsExpandableTrigger, +}: { + onMount: (distanceToTop: number) => void; + detailsExpandableTrigger: React.ReactNode; +}) => { + const router = useRouter(); + const params = useParams(); + const pathname = usePathname(); + const projectId = params?.projectId as string; + + const anchorRef = useRef(null); + + useEffect(() => { + if (onMount) { + const distanceToTop = anchorRef.current?.getBoundingClientRect().top ?? 0; + onMount(distanceToTop); + } + }, [onMount]); + + // Detect current route and set active tab + const getCurrentTab = (): string => { + const segments = pathname?.split("/"); + + if (!segments) { + throw new Error("URL Segments are empty."); + } + + const tabIndex = segments.findIndex((segment) => segment === projectId) + 1; + const currentTab = segments[tabIndex]; + + const validTabs = ["overview", "deployments", "logs", "settings"]; + return validTabs.includes(currentTab) ? currentTab : "overview"; + }; + + const activeTab = getCurrentTab(); + + const tabs: TabItem[] = [ + { + id: "overview", + label: "Overview", + icon: GridCircle, + path: `/projects/${projectId}`, + }, + { + id: "deployments", + label: "Deployments", + icon: Cloud, + path: `/projects/${projectId}/deployments`, + }, + { + id: "logs", + label: "Logs", + icon: Layers3, + path: `/projects/${projectId}/logs`, + }, + ]; + + const handleTabChange = (path: string) => { + router.push(path); + }; + + if (!projectId) { + throw new Error("ProjectSubNavigation requires a valid project ID"); + } + + return ( +
+
+ {tabs.map((tab) => { + const IconComponent = tab.icon || GridCircle; + const isActive = tab.id === activeTab; + return ( + // Our + ); + })} +
{detailsExpandableTrigger}
+
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/projects/[projectId]/page.tsx b/apps/dashboard/app/(app)/projects/[projectId]/page.tsx index 1d302611f0..0cabc2d6f0 100644 --- a/apps/dashboard/app/(app)/projects/[projectId]/page.tsx +++ b/apps/dashboard/app/(app)/projects/[projectId]/page.tsx @@ -1,23 +1,95 @@ "use client"; +import { Cloud, Earth, FolderCloud, Page2 } from "@unkey/icons"; +import { cn } from "@unkey/ui/src/lib/utils"; +import type { ReactNode } from "react"; +import { ActiveDeploymentCard } from "./details/active-deployment-card"; +import { DomainRow } from "./details/domain-row"; +import { EnvironmentVariablesSection } from "./details/env-variables-section"; +import { useProjectLayout } from "./layout-provider"; -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"; +const DOMAINS = [ + { + domain: "api.gateway.com", + status: "success" as const, + tags: ["https", "primary"], + }, + { + domain: "dev.gateway.com", + status: "error" as const, + tags: ["https", "primary"], + }, + { + domain: "staging.gateway.com", + status: "success" as const, + tags: ["https", "primary"], + }, +]; + +export default function ProjectDetails() { + const { isDetailsOpen, projectId } = useProjectLayout(); -export default function Deployments({ - params: { projectId }, -}: { - params: { projectId: string }; -}) { return ( -
- -
- - - +
+
+
+ } + title="Active Deployment" + /> + +
+ +
+ } + title="Domains" + /> +
+ {DOMAINS.map((domain) => ( + + ))} +
+
+ +
+ } + title="Environment Variables" + /> +
+ } + title="Production" + projectId={projectId} + environment="production" + /> + } + title="Preview" + projectId={projectId} + environment="preview" + /> +
+
); } + +function SectionHeader({ icon, title }: { icon: ReactNode; title: string }) { + return ( +
+ {icon} +
{title}
+
+ ); +} + +function Section({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/apps/dashboard/lib/trpc/routers/deploy/project/active-deployment/getBuildLogs.ts b/apps/dashboard/lib/trpc/routers/deploy/project/active-deployment/getBuildLogs.ts new file mode 100644 index 0000000000..3df647abaa --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/deploy/project/active-deployment/getBuildLogs.ts @@ -0,0 +1,230 @@ +import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { z } from "zod"; + +const LogLevel = z.enum(["info", "warning", "error"]); + +const LogEntry = z.object({ + id: z.string(), + timestamp: z.number(), + level: LogLevel.optional(), + message: z.string(), +}); + +const deploymentLogsInputSchema = z.object({ + deploymentId: z.string(), +}); + +const deploymentLogsOutputSchema = z.object({ + logs: z.array(LogEntry), +}); + +const MOCK_LOGS: DeploymentLog[] = [ + { + id: "log_1", + timestamp: Date.now() - 360000, + message: "Running build in us-east-1 (Washington, D.C.) — iad1", + }, + { + id: "log_2", + timestamp: Date.now() - 359000, + message: "Cloning github.com/acme/api (Branch: main, Commit: e5f6a7b)", + level: "error", + }, + { + id: "log_3", + timestamp: Date.now() - 358000, + message: "Build cache not found for this project", + }, + { + id: "log_4", + timestamp: Date.now() - 357000, + message: "Clone completed in 307ms", + level: "warning", + }, + { + id: "log_5", + timestamp: Date.now() - 356000, + message: "Running `unkey build`", + }, + { + id: "log_6", + timestamp: Date.now() - 355000, + message: "Unkey CLI 0.42.1", + }, + { + id: "log_7", + timestamp: Date.now() - 354000, + message: "Validating config files...", + }, + { + id: "log_8", + timestamp: Date.now() - 353000, + message: "✓ env-vars.json validated", + }, + { + id: "log_9", + timestamp: Date.now() - 352000, + message: "✓ runtime.json validated", + }, + { + id: "log_10", + timestamp: Date.now() - 351000, + message: "✓ secrets.json decrypted successfully", + }, + { + id: "log_11", + timestamp: Date.now() - 350000, + message: "✓ openapi.yaml parsed — 13 endpoints detected", + }, + { + id: "log_12", + timestamp: Date.now() - 349000, + message: '⚠️ Warning: Environment variable "STRIPE_SECRET" is not set. Using fallback value', + level: "warning", + }, + { + id: "log_13", + timestamp: Date.now() - 348000, + message: "Setting up runtime environment", + }, + { + id: "log_14", + timestamp: Date.now() - 347000, + message: "Target image: unkey:latest", + }, + { + id: "log_15", + timestamp: Date.now() - 346000, + message: "Build environment: nodejs18.x | Linux (x64)", + }, + { + id: "log_16", + timestamp: Date.now() - 345000, + message: "Installing dependencies...", + }, + { + id: "log_17", + timestamp: Date.now() - 344000, + message: "✓ Dependencies installed in 1.3s", + }, + { + id: "log_18", + timestamp: Date.now() - 343000, + message: "Compiling project...", + }, + { + id: "log_19", + timestamp: Date.now() - 342000, + message: "✓ Build successful in 331ms", + }, + { + id: "log_20", + timestamp: Date.now() - 341000, + message: "Registering healthcheck: GET /health every 30s", + }, + { + id: "log_21", + timestamp: Date.now() - 340000, + message: "Checking availability in selected regions...", + }, + { + id: "log_22", + timestamp: Date.now() - 339000, + message: "✓ us-east-1 available (2 slots)", + }, + { + id: "log_23", + timestamp: Date.now() - 338000, + message: "✓ eu-west-1 available (1 slot)", + }, + { + id: "log_24", + timestamp: Date.now() - 337000, + message: "✓ ap-south-1 available (1 slot)", + }, + { + id: "log_25", + timestamp: Date.now() - 336000, + message: "Creating deployment image...", + }, + { + id: "log_26", + timestamp: Date.now() - 335000, + message: + "❌ Error: Failed to optimize image layer for region eu-west-1. Using fallback strategy", + level: "error", + }, + { + id: "log_27", + timestamp: Date.now() - 334000, + message: "✓ Image built: 210mb", + }, + { + id: "log_28", + timestamp: Date.now() - 333000, + message: "Launching 4 VM instances", + }, + { + id: "log_29", + timestamp: Date.now() - 332000, + message: "✓ Scaling enabled: 0–5 instances at 80% CPU", + }, + { + id: "log_30", + timestamp: Date.now() - 331000, + message: "Deploying to:", + }, + { + id: "log_31", + timestamp: Date.now() - 330000, + message: " - api.gateway.com (https)", + }, + { + id: "log_32", + timestamp: Date.now() - 329000, + message: " - internal.api.gateway.com (http)", + }, + { + id: "log_33", + timestamp: Date.now() - 328000, + message: " - dashboard:3000, 8080, 5792", + }, + { + id: "log_34", + timestamp: Date.now() - 327000, + message: "Activating deployment: v_alpha001", + }, + { + id: "log_35", + timestamp: Date.now() - 326000, + message: "✓ Deployment active", + }, + { + id: "log_36", + timestamp: Date.now() - 325000, + message: "View logs at /dashboard/logs/alpha001", + }, + { + id: "log_37", + timestamp: Date.now() - 324000, + message: "Deployment completed in 5.7s", + }, +]; + +export type DeploymentLog = z.infer; +export type DeploymentLogsInput = z.infer; + +export const getDeploymentBuildLogs = t.procedure + .use(requireUser) + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input(deploymentLogsInputSchema) + .output(deploymentLogsOutputSchema) + .query(async () => { + // In real implementation: fetch from database/logging service by deploymentId + // If not sorted, sort by timestamp asc for chronological build order + const sortedLogs = MOCK_LOGS.sort((a, b) => a.timestamp - b.timestamp); + return { + logs: sortedLogs, + }; + }); diff --git a/apps/dashboard/lib/trpc/routers/deploy/project/active-deployment/getDetails.ts b/apps/dashboard/lib/trpc/routers/deploy/project/active-deployment/getDetails.ts new file mode 100644 index 0000000000..970c1cc7ac --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/deploy/project/active-deployment/getDetails.ts @@ -0,0 +1,99 @@ +import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { z } from "zod"; + +const deploymentDetailsOutputSchema = z.object({ + // Active deployment + repository: z.object({ + owner: z.string(), + name: z.string(), + }), + branch: z.string(), + commit: z.string(), + description: z.string(), + image: z.string(), + author: z.object({ + name: z.string(), + avatar: z.string(), + }), + createdAt: z.number(), + + // Runtime settings + instances: z.number(), + regions: z.array(z.string()), + cpu: z.number(), + memory: z.number(), + storage: z.number(), + healthcheck: z.object({ + method: z.string(), + path: z.string(), + interval: z.number(), + }), + scaling: z.object({ + min: z.number(), + max: z.number(), + threshold: z.number(), + }), + + // Build info + imageSize: z.number(), + buildTime: z.number(), + buildStatus: z.enum(["success", "failed", "pending"]), + baseImage: z.string(), + builtAt: z.number(), +}); + +type DeploymentDetailsOutputSchema = z.infer; +export type DeploymentDetails = z.infer; + +export const getDeploymentDetails = t.procedure + .use(requireUser) + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input( + z.object({ + deploymentId: z.string(), + }), + ) + .output(deploymentDetailsOutputSchema) + .query(() => { + //TODO: This should make a db look-up find the "active" and "latest" and "prod" deployment + const details: DeploymentDetailsOutputSchema = { + repository: { + owner: "acme", + name: "acme", + }, + branch: "main", + commit: "e5f6a7b", + description: "Add auth routes + logging", + image: "unkey:latest", + author: { + name: "Oz", + avatar: "https://avatars.githubusercontent.com/u/138932600?s=48&v=4", + }, + createdAt: Date.now(), + + instances: 4, + regions: ["eu-west-2", "us-east-1", "ap-southeast-1"], + cpu: 32, + memory: 512, + storage: 1024, + healthcheck: { + method: "GET", + path: "/health", + interval: 30, + }, + scaling: { + min: 0, + max: 5, + threshold: 80, + }, + + imageSize: 210, + buildTime: 45, + buildStatus: "success", + baseImage: "node:18-alpine", + builtAt: Date.now() - 300000, + }; + + return details; + }); diff --git a/apps/dashboard/lib/trpc/routers/deploy/project/envs/list.ts b/apps/dashboard/lib/trpc/routers/deploy/project/envs/list.ts new file mode 100644 index 0000000000..d5fda50ad1 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/deploy/project/envs/list.ts @@ -0,0 +1,87 @@ +import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; +import { z } from "zod"; + +const envVarSchema = z.object({ + id: z.string(), + key: z.string(), + value: z.string(), + isSecret: z.boolean(), +}); + +const environmentVariablesOutputSchema = z.object({ + production: z.array(envVarSchema), + preview: z.array(envVarSchema), + development: z.array(envVarSchema).optional(), +}); + +export type EnvironmentVariables = z.infer; +export type EnvVar = z.infer; + +export const VARIABLES: EnvironmentVariables = { + production: [ + { + id: "1", + key: "DATABASE_URL", + value: "postgresql://user:pass@prod.db.com:5432/app", + isSecret: true, + }, + { + id: "2", + key: "API_KEY", + value: "sk_prod_1234567890abcdef", + isSecret: true, + }, + { + id: "3", + key: "NODE_ENV", + value: "production", + isSecret: false, + }, + { + id: "4", + key: "REDIS_URL", + value: "redis://prod.redis.com:6379", + isSecret: true, + }, + { + id: "5", + key: "LOG_LEVEL", + value: "info", + isSecret: false, + }, + ], + preview: [ + { + id: "6", + key: "DATABASE_URL", + value: "postgresql://user:pass@staging.db.com:5432/app", + isSecret: true, + }, + { + id: "7", + key: "API_KEY", + value: "sk_test_abcdef1234567890", + isSecret: true, + }, + { + id: "8", + key: "NODE_ENV", + value: "development", + isSecret: false, + }, + ], +}; + +export const getEnvs = t.procedure + .use(requireUser) + .use(requireWorkspace) + .use(withRatelimit(ratelimit.read)) + .input( + z.object({ + projectId: z.string(), + }), + ) + .output(environmentVariablesOutputSchema) + .query(() => { + return VARIABLES; + }); diff --git a/apps/dashboard/lib/trpc/routers/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index 242718239c..c6e8f0e7f3 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -37,9 +37,12 @@ import { searchRolesPermissions } from "./authorization/roles/permissions/search import { queryRoles } from "./authorization/roles/query"; import { upsertRole } from "./authorization/roles/upsert"; import { queryUsage } from "./billing/query-usage"; +import { getDeploymentBuildLogs } from "./deploy/project/active-deployment/getBuildLogs"; +import { getDeploymentDetails } from "./deploy/project/active-deployment/getDetails"; import { createProject } from "./deploy/project/create"; import { queryDeployments } from "./deploy/project/deployment/list"; import { deploymentListLlmSearch } from "./deploy/project/deployment/llm-search"; +import { getEnvs } from "./deploy/project/envs/list"; import { queryProjects } from "./deploy/project/list"; import { deploymentRouter } from "./deployment"; import { createIdentity } from "./identity/create"; @@ -313,10 +316,17 @@ export const router = t.router({ project: t.router({ list: queryProjects, create: createProject, - }), - deployment: t.router({ - list: queryDeployments, - search: deploymentListLlmSearch, + activeDeployment: t.router({ + details: getDeploymentDetails, + buildLogs: getDeploymentBuildLogs, + }), + envs: t.router({ + getEnvs, + }), + deployment: t.router({ + list: queryDeployments, + search: deploymentListLlmSearch, + }), }), }), deployment: deploymentRouter, diff --git a/internal/icons/src/icons/bolt.tsx b/internal/icons/src/icons/bolt.tsx index 2dc0e8ede1..38c3690e0f 100644 --- a/internal/icons/src/icons/bolt.tsx +++ b/internal/icons/src/icons/bolt.tsx @@ -10,11 +10,18 @@ * https://nucleoapp.com/license */ import type React from "react"; -import type { IconProps } from "../props"; +import { type IconProps, sizeMap } from "../props"; -export const Bolt: React.FC = (props) => { +export const Bolt: React.FC = ({ size = "xl-thin", filled, ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size]; return ( - + = (props) => { stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" - strokeWidth="1.5" + strokeWidth={strokeWidth} /> diff --git a/internal/icons/src/icons/chart-activity.tsx b/internal/icons/src/icons/chart-activity.tsx new file mode 100644 index 0000000000..d67b9bfb78 --- /dev/null +++ b/internal/icons/src/icons/chart-activity.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 ChartActivity: React.FC = ({ size = "xl-thin", filled, ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size]; + + return ( + + + + + + ); +}; diff --git a/internal/icons/src/icons/chevron-down.tsx b/internal/icons/src/icons/chevron-down.tsx index a20ee7192d..c0c49daffd 100644 --- a/internal/icons/src/icons/chevron-down.tsx +++ b/internal/icons/src/icons/chevron-down.tsx @@ -13,7 +13,7 @@ import type React from "react"; import { type IconProps, sizeMap } from "../props"; export const ChevronDown: React.FC = ({ size = "xl-thin", ...props }) => { - const { size: pixelSize } = sizeMap[size]; + const { size: pixelSize, strokeWidth } = sizeMap[size]; return ( = ({ size = "xl-thin", ...props }) diff --git a/internal/icons/src/icons/circle-xmark.tsx b/internal/icons/src/icons/circle-xmark.tsx new file mode 100644 index 0000000000..b9a5ebe000 --- /dev/null +++ b/internal/icons/src/icons/circle-xmark.tsx @@ -0,0 +1,62 @@ +/** + * 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 CircleXMark: React.FC = ({ size, filled, ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size || "md-regular"]; + return ( + + + + + + + + ); +}; diff --git a/internal/icons/src/icons/code-commit.tsx b/internal/icons/src/icons/code-commit.tsx index 0af3a134c0..bd0d784cd3 100644 --- a/internal/icons/src/icons/code-commit.tsx +++ b/internal/icons/src/icons/code-commit.tsx @@ -19,19 +19,43 @@ export const CodeCommit: React.FC = ({ size = "xl-thin", ...props }) - - - + + + - ); diff --git a/internal/icons/src/icons/connections.tsx b/internal/icons/src/icons/connections.tsx index f4f0331f79..52a8870ad0 100644 --- a/internal/icons/src/icons/connections.tsx +++ b/internal/icons/src/icons/connections.tsx @@ -19,29 +19,33 @@ export const Connections: React.FC = ({ size = "xl-thin", ...props }) - - + - - diff --git a/internal/icons/src/icons/double-chevron-left.tsx b/internal/icons/src/icons/double-chevron-left.tsx new file mode 100644 index 0000000000..1aa6abda23 --- /dev/null +++ b/internal/icons/src/icons/double-chevron-left.tsx @@ -0,0 +1,45 @@ +/** + * 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 DoubleChevronLeft: React.FC = ({ size = "xl-thin", filled, ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size]; + return ( + + + + + + + ); +}; diff --git a/internal/icons/src/icons/double-chevron-right.tsx b/internal/icons/src/icons/double-chevron-right.tsx new file mode 100644 index 0000000000..601fe4b90f --- /dev/null +++ b/internal/icons/src/icons/double-chevron-right.tsx @@ -0,0 +1,45 @@ +/** + * 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 DoubleChevronRight: React.FC = ({ size = "xl-thin", filled, ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size]; + return ( + + + + + + + ); +}; diff --git a/internal/icons/src/icons/grid-circle.tsx b/internal/icons/src/icons/grid-circle.tsx new file mode 100644 index 0000000000..7db8cadebd --- /dev/null +++ b/internal/icons/src/icons/grid-circle.tsx @@ -0,0 +1,69 @@ +/** + * 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 GridCircle: React.FC = ({ size = "xl-thin", ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size]; + return ( + + + + + + + + + ); +}; diff --git a/internal/icons/src/icons/hard-drive.tsx b/internal/icons/src/icons/hard-drive.tsx new file mode 100644 index 0000000000..05f4fbdcfb --- /dev/null +++ b/internal/icons/src/icons/hard-drive.tsx @@ -0,0 +1,53 @@ +/** + * 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 Harddrive: React.FC = ({ size = "xl-thin", filled, ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size]; + return ( + + + + + + + + ); +}; diff --git a/internal/icons/src/icons/heart.tsx b/internal/icons/src/icons/heart.tsx new file mode 100644 index 0000000000..b697d90544 --- /dev/null +++ b/internal/icons/src/icons/heart.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 Heart: React.FC = ({ size = "xl-thin", filled, ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size]; + + return ( + + + + + + ); +}; diff --git a/internal/icons/src/icons/list-radio.tsx b/internal/icons/src/icons/list-radio.tsx new file mode 100644 index 0000000000..5ea1787a2a --- /dev/null +++ b/internal/icons/src/icons/list-radio.tsx @@ -0,0 +1,72 @@ +/** + * 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 ListRadio: React.FC = ({ size = "xl-thin", ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size]; + + return ( + + + + + + + + + ); +}; diff --git a/internal/icons/src/icons/location2.tsx b/internal/icons/src/icons/location2.tsx new file mode 100644 index 0000000000..8fb1b2565d --- /dev/null +++ b/internal/icons/src/icons/location2.tsx @@ -0,0 +1,59 @@ +/** + * 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 Location2: React.FC = ({ size = "xl-thin", ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size]; + + return ( + + + + + + + + ); +}; diff --git a/internal/icons/src/icons/message-writing.tsx b/internal/icons/src/icons/message-writing.tsx new file mode 100644 index 0000000000..f3197b984b --- /dev/null +++ b/internal/icons/src/icons/message-writing.tsx @@ -0,0 +1,56 @@ +/** + * 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 MessageWriting: React.FC = ({ size, filled, ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size || "md-regular"]; + + return ( + + + + + + + + + ); +}; diff --git a/internal/icons/src/icons/paperclip-2.tsx b/internal/icons/src/icons/paperclip-2.tsx new file mode 100644 index 0000000000..d2a897fe76 --- /dev/null +++ b/internal/icons/src/icons/paperclip-2.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 PaperClip2: React.FC = ({ size = "xl-thin", ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size]; + + return ( + + + + + + ); +}; diff --git a/internal/icons/src/icons/share-up-right.tsx b/internal/icons/src/icons/share-up-right.tsx new file mode 100644 index 0000000000..95cf3b2387 --- /dev/null +++ b/internal/icons/src/icons/share-up-right.tsx @@ -0,0 +1,56 @@ +/** + * 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 ShareUpRight: React.FC = ({ size = "xl-thin", ...props }) => { + const { size: pixelSize, strokeWidth } = sizeMap[size]; + return ( + + + + + + + + ); +}; diff --git a/internal/icons/src/icons/triangle-warning-2.tsx b/internal/icons/src/icons/triangle-warning-2.tsx index c220cdfeec..f68770b43e 100644 --- a/internal/icons/src/icons/triangle-warning-2.tsx +++ b/internal/icons/src/icons/triangle-warning-2.tsx @@ -28,7 +28,7 @@ export const TriangleWarning2: React.FC = ({ size = "xl-thin", ...pro = ({ size = "xl-thin", ...pro strokeLinejoin="round" strokeWidth={strokeWidth} /> - + ); diff --git a/internal/icons/src/index.ts b/internal/icons/src/index.ts index bc2f1a021f..164c2c5974 100644 --- a/internal/icons/src/index.ts +++ b/internal/icons/src/index.ts @@ -24,6 +24,7 @@ export * from "./icons/caret-expand-y"; export * from "./icons/caret-right"; export * from "./icons/caret-right-outline"; export * from "./icons/caret-up"; +export * from "./icons/chart-activity"; export * from "./icons/chart-activity-2"; export * from "./icons/chart-bar-axis-y"; export * from "./icons/chart-pie"; @@ -45,6 +46,7 @@ export * from "./icons/circle-info-sparkle"; export * from "./icons/circle-lock"; export * from "./icons/circle-question"; export * from "./icons/circle-warning"; +export * from "./icons/circle-xmark"; export * from "./icons/clipboard"; export * from "./icons/clipboard-check"; export * from "./icons/clock"; @@ -60,6 +62,8 @@ export * from "./icons/connections"; export * from "./icons/conversion"; export * from "./icons/cube"; export * from "./icons/dots"; +export * from "./icons/double-chevron-left"; +export * from "./icons/double-chevron-right"; export * from "./icons/earth"; export * from "./icons/eye-slash"; export * from "./icons/eye"; @@ -70,7 +74,10 @@ export * from "./icons/gauge"; export * from "./icons/gear"; export * from "./icons/github"; export * from "./icons/grid"; +export * from "./icons/grid-circle"; export * from "./icons/half-dotted-circle-play"; +export * from "./icons/hard-drive"; +export * from "./icons/heart"; export * from "./icons/input-password-edit"; export * from "./icons/input-password-settings"; export * from "./icons/input-search"; @@ -80,19 +87,24 @@ export * from "./icons/laptop-2"; export * from "./icons/layers-2"; export * from "./icons/layers-3"; export * from "./icons/link-4"; +export * from "./icons/list-radio"; +export * from "./icons/location2"; export * from "./icons/lock"; export * from "./icons/magnifier"; +export * from "./icons/message-writing"; export * from "./icons/minus"; 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/paperclip-2"; export * from "./icons/pen-writing-3"; export * from "./icons/plus"; export * from "./icons/progress-bar"; export * from "./icons/pulse"; export * from "./icons/refresh-3"; +export * from "./icons/share-up-right"; export * from "./icons/shield"; export * from "./icons/shield-alert"; export * from "./icons/shield-check";