diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/settings/components/default-bytes.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/settings/components/default-bytes.tsx index a5070d190b..d9d642569f 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/settings/components/default-bytes.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/settings/components/default-bytes.tsx @@ -75,7 +75,7 @@ export const DefaultBytes: React.FC = ({ keyAuth, apiId }) => { } border="top" - className="border-b" + className="border-b border-grayA-4" contentWidth="w-full lg:w-[420px] h-full justify-end items-end" >
= ({ api }) => { ) } border="top" - className="border-b" + className="border-b border-grayA-4" >
{api.deleteProtection ? ( diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/add-custom-domain.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/add-custom-domain.tsx index e4aa2ba281..e8583e2db6 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/add-custom-domain.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/add-custom-domain.tsx @@ -1,5 +1,5 @@ "use client"; -import { collection } from "@/lib/collections"; +import { type CustomDomain, collection } from "@/lib/collections"; import { cn } from "@/lib/utils"; import { Button, @@ -12,7 +12,6 @@ import { } from "@unkey/ui"; import { useEffect, useRef, useState } from "react"; import { useProjectData } from "../../data-provider"; -import type { CustomDomain } from "./types"; // Basic domain validation regex const DOMAIN_REGEX = /^(?!:\/\/)([a-zA-Z0-9-_]+\.)+[a-zA-Z]{2,}$/; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/index.tsx index f8212f0efd..c49ec1a334 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/index.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/index.tsx @@ -5,8 +5,8 @@ import { cn } from "@unkey/ui/src/lib/utils"; import { useState } from "react"; import { EmptySection } from "../../components/empty-section"; import { useProjectData } from "../../data-provider"; +import { CustomDomainRow } from "../../settings/components/advanced-settings/custom-domains/custom-domain-row"; import { AddCustomDomain } from "./add-custom-domain"; -import { CustomDomainRow, CustomDomainRowSkeleton } from "./custom-domain-row"; type CustomDomainsSectionProps = { environments: Array<{ id: string; slug: string }>; @@ -85,3 +85,15 @@ function EmptyState({ onAdd, hasEnvironments }: { onAdd: () => void; hasEnvironm ); } + +export function CustomDomainRowSkeleton() { + return ( +
+
+
+
+
+
+
+ ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/types.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/types.ts deleted file mode 100644 index 21125e27bc..0000000000 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/types.ts +++ /dev/null @@ -1 +0,0 @@ -export type { CustomDomain, VerificationStatus } from "@/lib/collections/deploy/custom-domains"; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/command.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/command.tsx new file mode 100644 index 0000000000..6af7c66097 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/command.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { trpc } from "@/lib/trpc/client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { SquareTerminal } from "@unkey/icons"; +import { FormTextarea, InfoTooltip, toast } from "@unkey/ui"; +import { useEffect } from "react"; +import { useForm, useWatch } from "react-hook-form"; +import { z } from "zod"; +import { useProjectData } from "../../../data-provider"; +import { FormSettingCard } from "../shared/form-setting-card"; + +const commandSchema = z.object({ + command: z.string(), +}); + +type CommandFormValues = z.infer; + +export const Command = () => { + const { environments } = useProjectData(); + const environmentId = environments[0]?.id; + + const { data: settingsData } = trpc.deploy.environmentSettings.get.useQuery( + { environmentId: environmentId ?? "" }, + { enabled: Boolean(environmentId) }, + ); + + const rawCommand = settingsData?.runtimeSettings?.command as string[] | undefined; + const defaultCommand = (rawCommand ?? []).join(" "); + + return ; +}; + +type CommandFormProps = { + environmentId: string; + defaultCommand: string; +}; + +const CommandForm: React.FC = ({ environmentId, defaultCommand }) => { + const utils = trpc.useUtils(); + + const { + register, + handleSubmit, + formState: { isValid, isSubmitting, errors }, + control, + reset, + } = useForm({ + resolver: zodResolver(commandSchema), + mode: "onChange", + defaultValues: { command: defaultCommand }, + }); + + useEffect(() => { + reset({ command: defaultCommand }); + }, [defaultCommand, reset]); + + const currentCommand = useWatch({ control, name: "command" }); + const hasChanges = currentCommand !== defaultCommand; + + const updateCommand = trpc.deploy.environmentSettings.runtime.updateCommand.useMutation({ + onSuccess: () => { + toast.success("Command updated"); + utils.deploy.environmentSettings.get.invalidate({ environmentId }); + }, + onError: (err) => { + toast.error("Failed to update command", { + description: err.message, + }); + }, + }); + + const onSubmit = async (values: CommandFormValues) => { + const trimmed = values.command.trim(); + const command = trimmed === "" ? [] : trimmed.split(/\s+/).filter(Boolean); + await updateCommand.mutateAsync({ environmentId, command }); + }; + + return ( + } + title="Command" + description="The command to start your application. Changes apply on next deploy." + displayValue={ + defaultCommand ? ( + + + {defaultCommand} + + + ) : ( + Default + ) + } + onSubmit={handleSubmit(onSubmit)} + canSave={isValid && !isSubmitting && hasChanges} + isSaving={updateCommand.isLoading || isSubmitting} + > + + + ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/custom-domain-row.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/custom-domains/custom-domain-row.tsx similarity index 86% rename from web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/custom-domain-row.tsx rename to web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/custom-domains/custom-domain-row.tsx index 22d3349f98..2e78ea0176 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/details/custom-domains-section/custom-domain-row.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/custom-domains/custom-domain-row.tsx @@ -1,12 +1,15 @@ "use client"; import { collection } from "@/lib/collections"; -import { retryDomainVerification } from "@/lib/collections/deploy/custom-domains"; +import { + type CustomDomain, + type VerificationStatus, + retryDomainVerification, +} from "@/lib/collections/deploy/custom-domains"; import { cn } from "@/lib/utils"; import { CircleCheck, CircleInfo, Clock, - Link4, Refresh3, Trash, TriangleWarning, @@ -22,11 +25,11 @@ import { TooltipTrigger, } from "@unkey/ui"; import { useRef, useState } from "react"; -import { useProjectData } from "../../data-provider"; -import type { CustomDomain, VerificationStatus } from "./types"; +import { useProjectData } from "../../../../data-provider"; type CustomDomainRowProps = { domain: CustomDomain; + environmentSlug?: string; }; const statusConfig: Record< @@ -55,7 +58,7 @@ const statusConfig: Record< }, }; -export function CustomDomainRow({ domain }: CustomDomainRowProps) { +export function CustomDomainRow({ domain, environmentSlug }: CustomDomainRowProps) { const { projectId } = useProjectData(); const [isConfirmOpen, setIsConfirmOpen] = useState(false); const [isRetrying, setIsRetrying] = useState(false); @@ -77,21 +80,25 @@ export function CustomDomainRow({ domain }: CustomDomainRowProps) { }; return ( -
+
- {domain.domain} + {environmentSlug && ( + + {environmentSlug} + + )}
-
+
{status.icon} {status.label} @@ -122,15 +129,15 @@ export function CustomDomainRow({ domain }: CustomDomainRowProps) { {domain.verificationError} )} - {deleteButtonRef.current && ( @@ -180,7 +187,7 @@ function DnsRecordTable({ const txtRecordValue = `unkey-domain-verify=${verificationToken}`; return ( -
+

Add both DNS records below at your domain provider.

{/* TXT Record (Ownership Verification) */} @@ -193,7 +200,7 @@ function DnsRecordTable({ />
-
+
Type Name Value @@ -230,7 +237,7 @@ function DnsRecordTable({ />
-
+
Type Name Value @@ -268,15 +275,3 @@ function StatusIndicator({ verified, label }: { verified: boolean; label: string ); } - -export function CustomDomainRowSkeleton() { - return ( -
-
-
-
-
-
-
- ); -} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/custom-domains/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/custom-domains/index.tsx new file mode 100644 index 0000000000..c1c262dbe9 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/custom-domains/index.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { collection } from "@/lib/collections"; +import type { CustomDomain } from "@/lib/collections/deploy/custom-domains"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { ChevronDown, Link4 } from "@unkey/icons"; +import { + FormInput, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@unkey/ui"; +import { Controller, useForm } from "react-hook-form"; +import { useProjectData } from "../../../../data-provider"; +import { FormSettingCard } from "../../shared/form-setting-card"; +import { CustomDomainRow } from "./custom-domain-row"; +import { type CustomDomainFormValues, customDomainSchema } from "./schema"; + +export const CustomDomains = () => { + const { environments, customDomains, projectId } = useProjectData(); + + const defaultEnvironmentId = + environments.find((e) => e.slug === "production")?.id ?? environments[0]?.id ?? ""; + + return ( + + ); +}; + +type CustomDomainSettingsProps = { + environments: { id: string; slug: string }[]; + customDomains: CustomDomain[]; + projectId: string; + defaultEnvironmentId: string; +}; + +const CustomDomainSettings: React.FC = ({ + environments, + customDomains, + projectId, + defaultEnvironmentId, +}) => { + const { + handleSubmit, + control, + register, + reset, + setError, + formState: { isValid, isSubmitting, errors }, + } = useForm({ + resolver: zodResolver(customDomainSchema), + mode: "onChange", + defaultValues: { + environmentId: defaultEnvironmentId, + domain: "", + }, + }); + + const onSubmit = (values: CustomDomainFormValues) => { + const trimmedDomain = values.domain.trim(); + if (customDomains.some((d) => d.domain === trimmedDomain)) { + setError("domain", { message: "Domain already registered" }); + return; + } + collection.customDomains.insert({ + id: crypto.randomUUID(), + domain: trimmedDomain, + workspaceId: "", + projectId, + environmentId: values.environmentId, + verificationStatus: "pending", + verificationToken: "", + ownershipVerified: false, + cnameVerified: false, + targetCname: "", + checkAttempts: 0, + lastCheckedAt: null, + verificationError: null, + createdAt: Date.now(), + updatedAt: null, + }); + reset({ environmentId: values.environmentId, domain: "" }); + }; + + const displayValue = () => { + if (customDomains.length === 0) { + return None; + } + return ( +
+ {customDomains.length} + + domain{customDomains.length !== 1 ? "s" : ""} + +
+ ); + }; + + return ( + } + title="Custom Domains" + description="Serve your deployment from your own domain name" + displayValue={displayValue()} + onSubmit={handleSubmit(onSubmit)} + canSave={isValid && !isSubmitting} + isSaving={isSubmitting} + > +
+
+ Environment + Domain +
+
+ ( + + )} + /> + +
+ + {customDomains.length > 0 && ( +
+ {customDomains.map((d) => ( + e.id === d.environmentId)?.slug} + /> + ))} +
+ )} +
+
+ ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/custom-domains/schema.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/custom-domains/schema.ts new file mode 100644 index 0000000000..20f3bce02a --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/custom-domains/schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const customDomainSchema = z.object({ + environmentId: z.string().min(1, "Environment is required"), + domain: z + .string() + .min(1, "Domain is required") + .regex(/^(?!:\/\/)([a-zA-Z0-9-_]+\.)+[a-zA-Z]{2,}$/, "Invalid domain format"), +}); + +export type CustomDomainFormValues = z.infer; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/env-var-row.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/env-var-row.tsx new file mode 100644 index 0000000000..fd5ba14d59 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/env-var-row.tsx @@ -0,0 +1,155 @@ +import { cn } from "@/lib/utils"; +import { ChevronDown, Eye, EyeSlash, Plus, Trash } from "@unkey/icons"; +import { + Button, + FormCheckbox, + FormInput, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@unkey/ui"; +import { useState } from "react"; +import { type Control, Controller, type UseFormRegister, useWatch } from "react-hook-form"; +import type { EnvVarsFormValues } from "./schema"; +import type { EnvVarItem } from "./utils"; + +type EnvVarRowProps = { + index: number; + isLast: boolean; + isOnly: boolean; + keyError: string | undefined; + environmentError: string | undefined; + defaultEnvVars: EnvVarItem[]; + environments: { id: string; slug: string }[]; + control: Control; + register: UseFormRegister; + onAdd: () => void; + onRemove: () => void; +}; + +export const EnvVarRow = ({ + index, + isLast, + isOnly, + keyError, + environmentError, + defaultEnvVars, + environments, + control, + register, + onAdd, + onRemove, +}: EnvVarRowProps) => { + const [isVisible, setIsVisible] = useState(false); + + // Watch this specific row's data - fixes index shift bug on delete + const currentVar = useWatch({ control, name: `envVars.${index}` }); + const isSecret = currentVar?.secret ?? false; + const isPreviouslyAdded = Boolean( + currentVar?.id && defaultEnvVars.some((v) => v.id === currentVar.id && v.key !== ""), + ); + + const inputType = isPreviouslyAdded ? (isVisible ? "text" : "password") : "text"; + + const eyeButton = + isPreviouslyAdded && !isSecret ? ( + + ) : undefined; + + return ( +
+
+ ( + + )} + /> +
+ + +
+ ( + + )} + /> +
+
+ + +
+
+ ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/index.tsx new file mode 100644 index 0000000000..b37d504ea1 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/index.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { trpc } from "@/lib/trpc/client"; +import { cn } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Nodes2 } from "@unkey/icons"; +import { toast } from "@unkey/ui"; +import { useMemo } from "react"; +import { useFieldArray, useForm } from "react-hook-form"; +import { useProjectData } from "../../../../data-provider"; +import { FormSettingCard } from "../../shared/form-setting-card"; +import { EnvVarRow } from "./env-var-row"; +import { type EnvVarsFormValues, createEmptyRow, envVarsSchema } from "./schema"; +import { useDecryptedValues } from "./use-decrypted-values"; +import { useDropZone } from "./use-drop-zone"; +import { computeEnvVarsDiff, groupByEnvironment, toTrpcType } from "./utils"; + +export const EnvVars = () => { + const { projectId, environments } = useProjectData(); + + const defaultEnvironmentId = + environments.find((e) => e.slug === "production")?.id ?? environments[0]?.id; + + const { data } = trpc.deploy.envVar.list.useQuery({ projectId }, { enabled: Boolean(projectId) }); + + const allVariables = useMemo(() => { + if (!data) { + return []; + } + return environments.flatMap((env) => { + const envData = data[env.slug]; + if (!envData) { + return []; + } + return envData.variables.map((v) => ({ + ...v, + environmentId: env.id, + })); + }); + }, [data, environments]); + + const { decryptedValues, isDecrypting } = useDecryptedValues(allVariables); + + const defaultValues = useMemo(() => { + if (allVariables.length === 0) { + return { envVars: [createEmptyRow(defaultEnvironmentId)] }; + } + return { + envVars: allVariables.map((v) => ({ + id: v.id, + environmentId: v.environmentId, + key: v.key, + value: v.type === "writeonly" ? "" : (decryptedValues[v.id] ?? ""), + secret: v.type === "writeonly", + })), + }; + }, [allVariables, decryptedValues, defaultEnvironmentId]); + + const formKey = useMemo(() => { + const varIds = allVariables.map((v) => v.id).join("-") || "empty"; + const decryptedIds = Object.keys(decryptedValues).sort().join("-") || "none"; + return `${varIds}:${decryptedIds}`; + }, [allVariables, decryptedValues]); + + if (!defaultEnvironmentId) { + return null; + } + + return ( + + ); +}; + +const EnvVarsForm = ({ + defaultValues, + defaultEnvironmentId, + environments, + projectId, + isDecrypting, +}: { + defaultValues: EnvVarsFormValues; + defaultEnvironmentId: string; + environments: { id: string; slug: string }[]; + projectId: string; + isDecrypting: boolean; +}) => { + const utils = trpc.useUtils(); + + const { + register, + handleSubmit, + formState: { isValid, isSubmitting, errors, isDirty }, + control, + reset, + } = useForm({ + resolver: zodResolver(envVarsSchema), + mode: "onChange", + defaultValues, + }); + + const { ref, isDragging } = useDropZone(reset, defaultEnvironmentId); + const { fields, append, remove } = useFieldArray({ control, name: "envVars" }); + + const createMutation = trpc.deploy.envVar.create.useMutation(); + const updateMutation = trpc.deploy.envVar.update.useMutation(); + const deleteMutation = trpc.deploy.envVar.delete.useMutation(); + + const isSaving = + createMutation.isLoading || + updateMutation.isLoading || + deleteMutation.isLoading || + isSubmitting; + + const onSubmit = async (values: EnvVarsFormValues) => { + const { toDelete, toCreate, toUpdate, originalMap } = computeEnvVarsDiff( + defaultValues.envVars, + values.envVars, + ); + + const createsByEnv = groupByEnvironment(toCreate); + + try { + await Promise.all([ + ...toDelete.map(async (id) => { + const key = originalMap.get(id)?.key ?? id; + try { + return await deleteMutation.mutateAsync({ envVarId: id }); + } catch (err) { + throw new Error(`"${key}": ${err instanceof Error ? err.message : "Failed to delete"}`); + } + }), + ...[...createsByEnv.entries()].map(([envId, vars]) => + createMutation.mutateAsync({ + environmentId: envId, + variables: vars.map((v) => ({ + key: v.key, + value: v.value, + type: toTrpcType(v.secret), + })), + }), + ), + ...toUpdate.map((v) => + updateMutation + .mutateAsync({ + envVarId: v.id as string, + key: v.key, + value: v.value, + type: toTrpcType(v.secret), + }) + .catch((err) => { + throw new Error( + `"${v.key}": ${err instanceof Error ? err.message : "Failed to update"}`, + ); + }), + ), + ]); + + utils.deploy.envVar.list.invalidate({ projectId }); + toast.success("Environment variables saved"); + } catch (err) { + toast.error("Failed to save environment variables", { + description: + err instanceof Error + ? err.message + : "An unexpected error occurred. Please try again or contact support@unkey.com", + }); + } + }; + + const varCount = defaultValues.envVars.filter((v) => v.key !== "").length; + const displayValue = + varCount === 0 ? ( + None + ) : ( +
+ {varCount} + variable{varCount !== 1 ? "s" : ""} +
+ ); + + return ( + } + title="Environment Variables" + description="Set environment variables available at runtime. Changes apply on next deploy." + displayValue={displayValue} + onSubmit={handleSubmit(onSubmit)} + canSave={isValid && !isSaving && !isDecrypting && isDirty} + isSaving={isSaving} + ref={ref} + className={cn("relative", isDragging && "bg-primary/5")} + > +
+
+

+ Drag & drop your .env file or + paste env vars (⌘V / Ctrl+V) +

+ +
+
+ Environment + Key + Value + Sensitive +
+
+ + {fields.map((field, index) => ( + append(createEmptyRow(defaultEnvironmentId))} + onRemove={() => remove(index)} + /> + ))} +
+
+ + ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/schema.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/schema.ts new file mode 100644 index 0000000000..25ea8cb8df --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/schema.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; + +export const envVarEntrySchema = z.object({ + id: z.string().optional(), + environmentId: z.string().min(1, "Environment is required"), + key: z + .string() + .min(1, "Key is required") + .regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "Must start with a letter or underscore"), + value: z.string(), + secret: z.boolean(), +}); + +export const envVarsSchema = z.object({ + envVars: z + .array(envVarEntrySchema) + .min(1) + .superRefine((vars, ctx) => { + const seen = new Map(); + for (let i = 0; i < vars.length; i++) { + const v = vars[i]; + if (!v.key) { + continue; + } + const compound = `${v.environmentId}::${v.key}`; + const prevIndex = seen.get(compound); + if (prevIndex !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Duplicate key in the same environment", + path: [i, "key"], + }); + } else { + seen.set(compound, i); + } + } + }), +}); + +export type EnvVarsFormValues = z.infer; + +export function createEmptyRow(environmentId: string): EnvVarsFormValues["envVars"][number] { + return { + key: "", + value: "", + secret: false, + environmentId, + }; +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/use-decrypted-values.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/use-decrypted-values.ts new file mode 100644 index 0000000000..8667a78ff0 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/use-decrypted-values.ts @@ -0,0 +1,50 @@ +import { trpc } from "@/lib/trpc/client"; +import { useEffect, useMemo, useState } from "react"; + +export type EnvVariable = { + id: string; + key: string; + type: "writeonly" | "recoverable"; +}; + +export function useDecryptedValues(variables: EnvVariable[]) { + const decryptMutation = trpc.deploy.envVar.decrypt.useMutation(); + const [decryptedValues, setDecryptedValues] = useState>({}); + const [isDecrypting, setIsDecrypting] = useState(false); + + const variableFingerprint = useMemo( + () => + variables + .map((v) => v.id) + .sort() + .join(","), + [variables], + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: its safe to keep + useEffect(() => { + if (variables.length === 0) { + return; + } + + const recoverableVars = variables.filter((v) => v.type === "recoverable"); + if (recoverableVars.length === 0) { + return; + } + + setIsDecrypting(true); + Promise.all( + recoverableVars.map((v) => + decryptMutation.mutateAsync({ envVarId: v.id }).then((r) => [v.id, r.value] as const), + ), + ) + .then((entries) => { + setDecryptedValues(Object.fromEntries(entries)); + }) + .finally(() => { + setIsDecrypting(false); + }); + }, [variableFingerprint]); + + return { decryptedValues, isDecrypting }; +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/use-drop-zone.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/use-drop-zone.ts new file mode 100644 index 0000000000..2eb59cb2ac --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/use-drop-zone.ts @@ -0,0 +1,169 @@ +import { toast } from "@unkey/ui"; +import { useEffect, useRef, useState } from "react"; +import type { UseFormReset } from "react-hook-form"; +import type { EnvVarsFormValues } from "./schema"; + +const parseEnvText = (text: string): Array<{ key: string; value: string; secret: boolean }> => { + const lines = text.trim().split("\n"); + return lines + .map((line) => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) { + return null; + } + + const eqIndex = trimmed.indexOf("="); + if (eqIndex === -1) { + return null; + } + + const key = trimmed.slice(0, eqIndex).trim(); + let value = trimmed.slice(eqIndex + 1).trim(); + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + return null; + } + + return { key, value, secret: false }; + }) + .filter((v): v is NonNullable => v !== null); +}; + +export function useDropZone(reset: UseFormReset, defaultEnvironmentId: string) { + const [isDragging, setIsDragging] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const dropZone = ref.current; + if (!dropZone) { + return; + } + + const handlePaste = async (e: ClipboardEvent) => { + const clipboardData = e.clipboardData; + if (!clipboardData) { + return; + } + + const files = clipboardData.files; + if (files.length > 0) { + const file = files[0]; + if (file.name.endsWith(".env") || file.type === "text/plain" || file.type === "") { + e.preventDefault(); + const text = await file.text(); + const parsed = parseEnvText(text); + if (parsed.length > 0) { + reset( + { + envVars: parsed.map((row) => ({ + ...row, + environmentId: defaultEnvironmentId, + })), + }, + { keepDefaultValues: true }, + ); + toast.success(`Imported ${parsed.length} variable(s)`); + } else { + toast.error("No valid environment variables found"); + } + return; + } + } + + const text = clipboardData.getData("text/plain"); + if (text?.includes("\n") && text?.includes("=")) { + e.preventDefault(); + const parsed = parseEnvText(text); + if (parsed.length > 0) { + reset( + { + envVars: parsed.map((row) => ({ + ...row, + environmentId: defaultEnvironmentId, + })), + }, + { keepDefaultValues: true }, + ); + toast.success(`Imported ${parsed.length} variable(s)`); + } else { + toast.error("No valid environment variables found"); + } + } + }; + + const handleDragEnter = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }; + + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDragLeave = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.currentTarget === dropZone && !dropZone.contains(e.relatedTarget as Node)) { + setIsDragging(false); + } + }; + + const handleDrop = async (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + const files = e.dataTransfer?.files; + if (!files || files.length === 0) { + return; + } + + const file = files[0]; + if (file.name.endsWith(".env") || file.type === "text/plain" || file.type === "") { + const text = await file.text(); + const parsed = parseEnvText(text); + if (parsed.length > 0) { + reset( + { + envVars: parsed.map((row) => ({ + ...row, + environmentId: defaultEnvironmentId, + })), + }, + { keepDefaultValues: true }, + ); + toast.success(`Imported ${parsed.length} variable(s)`); + } else { + toast.error("No valid environment variables found"); + } + } else { + toast.error("Please drop a .env or text file"); + } + }; + + dropZone.addEventListener("paste", handlePaste); + dropZone.addEventListener("dragenter", handleDragEnter); + dropZone.addEventListener("dragover", handleDragOver); + dropZone.addEventListener("dragleave", handleDragLeave); + dropZone.addEventListener("drop", handleDrop); + + return () => { + dropZone.removeEventListener("paste", handlePaste); + dropZone.removeEventListener("dragenter", handleDragEnter); + dropZone.removeEventListener("dragover", handleDragOver); + dropZone.removeEventListener("dragleave", handleDragLeave); + dropZone.removeEventListener("drop", handleDrop); + }; + }, [reset, defaultEnvironmentId]); + + return { ref, isDragging }; +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/utils.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/utils.ts new file mode 100644 index 0000000000..f17ce5f410 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/advanced-settings/env-vars/utils.ts @@ -0,0 +1,46 @@ +import type { EnvVarsFormValues } from "./schema"; + +export type EnvVarItem = EnvVarsFormValues["envVars"][number]; + +export const toTrpcType = (secret: boolean) => (secret ? "writeonly" : "recoverable"); + +export function computeEnvVarsDiff(original: EnvVarItem[], current: EnvVarItem[]) { + const originalVars = original.filter((v) => v.id); + const originalIds = new Set(originalVars.map((v) => v.id as string)); + const originalMap = new Map(originalVars.map((v) => [v.id as string, v])); + + const currentIds = new Set(current.filter((v) => v.id).map((v) => v.id as string)); + + const toDelete = [...originalIds].filter((id) => !currentIds.has(id)); + + const toCreate = current.filter((v) => !v.id && v.key !== "" && v.value !== ""); + + const toUpdate = current.filter((v) => { + if (!v.id) { + return false; + } + const orig = originalMap.get(v.id); + if (!orig) { + return false; + } + if (v.value === "") { + return false; + } + return v.key !== orig.key || v.value !== orig.value || v.secret !== orig.secret; + }); + + return { toDelete, toCreate, toUpdate, originalMap }; +} + +export function groupByEnvironment(items: EnvVarItem[]): Map { + const map = new Map(); + for (const item of items) { + const existing = map.get(item.environmentId); + if (existing) { + existing.push(item); + } else { + map.set(item.environmentId, [item]); + } + } + return map; +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings.tsx deleted file mode 100644 index 528dd420b5..0000000000 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings.tsx +++ /dev/null @@ -1,169 +0,0 @@ -"use client"; - -import { trpc } from "@/lib/trpc/client"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Button, FormInput, SettingCard, toast } from "@unkey/ui"; -import { useForm, useWatch } from "react-hook-form"; -import { z } from "zod"; - -type Props = { - environmentId: string; -}; - -const dockerContextSchema = z.object({ - dockerContext: z.string(), -}); - -const dockerfileSchema = z.object({ - dockerfile: z.string(), -}); - -const DockerContextCard: React.FC = ({ - environmentId, - defaultValue, -}) => { - const utils = trpc.useUtils(); - - const { - register, - handleSubmit, - formState: { isValid, isSubmitting }, - control, - } = useForm>({ - resolver: zodResolver(dockerContextSchema), - mode: "onChange", - defaultValues: { dockerContext: defaultValue }, - }); - - const currentDockerContext = useWatch({ control, name: "dockerContext" }); - - const updateBuild = trpc.deploy.environmentSettings.updateBuild.useMutation({ - onSuccess: () => { - toast.success("Docker context updated"); - utils.deploy.environmentSettings.get.invalidate({ environmentId }); - }, - onError: (err) => { - toast.error("Failed to update docker context", { - description: err.message, - }); - }, - }); - - const onSubmit = async (values: z.infer) => { - await updateBuild.mutateAsync({ - environmentId, - dockerContext: values.dockerContext, - }); - }; - - return ( - - -
- - -
-
- - ); -}; - -const DockerfileCard: React.FC = ({ - environmentId, - defaultValue, -}) => { - const utils = trpc.useUtils(); - - const { - register, - handleSubmit, - formState: { isValid, isSubmitting }, - control, - } = useForm>({ - resolver: zodResolver(dockerfileSchema), - mode: "onChange", - defaultValues: { dockerfile: defaultValue }, - }); - - const currentDockerfile = useWatch({ control, name: "dockerfile" }); - - const updateBuild = trpc.deploy.environmentSettings.updateBuild.useMutation({ - onSuccess: () => { - toast.success("Dockerfile updated"); - utils.deploy.environmentSettings.get.invalidate({ environmentId }); - }, - onError: (err) => { - toast.error("Failed to update dockerfile", { - description: err.message, - }); - }, - }); - - const onSubmit = async (values: z.infer) => { - await updateBuild.mutateAsync({ environmentId, dockerfile: values.dockerfile }); - }; - - return ( -
- -
- - -
-
-
- ); -}; - -export const BuildSettings: React.FC = ({ environmentId }) => { - const { data } = trpc.deploy.environmentSettings.get.useQuery({ environmentId }); - - return ( -
- - -
- ); -}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/dockerfile-settings.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/dockerfile-settings.tsx new file mode 100644 index 0000000000..59e2299afb --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/dockerfile-settings.tsx @@ -0,0 +1,102 @@ +import { trpc } from "@/lib/trpc/client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { FileSettings } from "@unkey/icons"; +import { FormInput, toast } from "@unkey/ui"; +import { useForm, useWatch } from "react-hook-form"; +import { z } from "zod"; +import { useProjectData } from "../../../data-provider"; +import { FormSettingCard } from "../shared/form-setting-card"; + +const dockerfileSchema = z.object({ + dockerfile: z.string().min(1, "Dockerfile path is required"), +}); + +export const DockerfileSettings = () => { + const { environments } = useProjectData(); + const environmentId = environments[0]?.id; + + const { data } = trpc.deploy.environmentSettings.get.useQuery( + { environmentId: environmentId ?? "" }, + { enabled: Boolean(environmentId) }, + ); + + const defaultValue = data?.buildSettings?.dockerfile ?? "Dockerfile"; + return ; +}; + +const DockerfileForm = ({ + environmentId, + defaultValue, +}: { + environmentId: string; + defaultValue: string; +}) => { + const utils = trpc.useUtils(); + + const { + register, + handleSubmit, + formState: { isValid, isSubmitting, errors }, + control, + } = useForm>({ + resolver: zodResolver(dockerfileSchema), + mode: "onChange", + defaultValues: { dockerfile: defaultValue }, + }); + + const currentDockerfile = useWatch({ control, name: "dockerfile" }); + + const updateDockerfile = trpc.deploy.environmentSettings.build.updateDockerfile.useMutation({ + onSuccess: (_data, variables) => { + toast.success("Dockerfile updated", { + description: `Path set to "${variables.dockerfile ?? defaultValue}".`, + duration: 5000, + }); + utils.deploy.environmentSettings.get.invalidate({ environmentId }); + }, + onError: (err) => { + if (err.data?.code === "BAD_REQUEST") { + toast.error("Invalid Dockerfile path", { + description: err.message || "Please check your input and try again.", + }); + } else { + toast.error("Failed to update Dockerfile", { + description: + err.message || + "An unexpected error occurred. Please try again or contact support@unkey.com", + action: { + label: "Contact Support", + onClick: () => window.open("mailto:support@unkey.com", "_blank"), + }, + }); + } + }, + }); + + const onSubmit = async (values: z.infer) => { + await updateDockerfile.mutateAsync({ environmentId, dockerfile: values.dockerfile }); + }; + + return ( + } + title="Dockerfile" + description="Dockerfile location used for docker build. (e.g., services/api/Dockerfile)" + displayValue={defaultValue} + onSubmit={handleSubmit(onSubmit)} + canSave={isValid && !isSubmitting && currentDockerfile !== defaultValue} + isSaving={updateDockerfile.isLoading || isSubmitting} + > + + + ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/github-settings/github-connected.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/github-settings/github-connected.tsx new file mode 100644 index 0000000000..b4f510c8c7 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/github-settings/github-connected.tsx @@ -0,0 +1,70 @@ +import { trpc } from "@/lib/trpc/client"; +import { Button, InfoTooltip, toast } from "@unkey/ui"; +import { SelectedConfig } from "../../shared/selected-config"; +import { GitHubSettingCard, ManageGitHubAppLink, RepoNameLabel } from "./shared"; + +export const GitHubConnected = ({ + projectId, + installUrl, + repoFullName, +}: { + projectId: string; + installUrl: string; + repoFullName: string; +}) => { + const utils = trpc.useUtils(); + + const disconnectRepoMutation = trpc.github.disconnectRepo.useMutation({ + onSuccess: async () => { + toast.success("Repository disconnected"); + await utils.github.getInstallations.invalidate(); + }, + onError: (error) => { + toast.error(error.message); + }, + }); + + const collapsed = ( + + } /> + + ); + + const expandable = ( +
+ + Pushes to this repository will trigger deployments. + +
+
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}> + +
+ Manage GitHub} + /> +
+
+ ); + + return ( + + {collapsed} + + ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/github-settings/github-no-repo.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/github-settings/github-no-repo.tsx new file mode 100644 index 0000000000..78c95034b6 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/github-settings/github-no-repo.tsx @@ -0,0 +1,92 @@ +import { Combobox } from "@/components/ui/combobox"; +import { trpc } from "@/lib/trpc/client"; +import { toast } from "@unkey/ui"; +import { useMemo, useState } from "react"; +import { ComboboxSkeleton, GitHubSettingCard, ManageGitHubAppLink, RepoNameLabel } from "./shared"; + +export const GitHubNoRepo = ({ + projectId, + installUrl, +}: { + projectId: string; + installUrl: string; +}) => { + const utils = trpc.useUtils(); + const [selectedRepo, setSelectedRepo] = useState(""); + + const { data: reposData, isLoading: isLoadingRepos } = trpc.github.listRepositories.useQuery( + { + projectId, + }, + { + refetchOnWindowFocus: false, + }, + ); + + const selectRepoMutation = trpc.github.selectRepository.useMutation({ + onSuccess: async () => { + toast.success("Repository connected"); + await utils.github.getInstallations.invalidate(); + }, + onError: (error) => { + toast.error(error.message); + }, + }); + + const repoOptions = useMemo( + () => + (reposData?.repositories ?? []).map((repo) => ({ + value: `${repo.installationId}:${repo.id}`, + label: , + searchValue: repo.fullName, + selectedLabel: , + })), + [reposData?.repositories], + ); + + const handleSelectRepository = (value: string) => { + setSelectedRepo(value); + const repo = reposData?.repositories.find((r) => `${r.installationId}:${r.id}` === value); + if (!repo) { + return; + } + selectRepoMutation.mutate({ + projectId, + repositoryId: repo.id, + repositoryFullName: repo.fullName, + installationId: repo.installationId, + }); + }; + + const collapsed = ( +
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}> + {isLoadingRepos ? ( + + ) : repoOptions.length ? ( + Select a repository... + searchPlaceholder="Filter repositories..." + disabled={selectRepoMutation.isLoading} + /> + ) : ( + + Import from + GitHub + + } + /> + )} +
+ ); + + return {collapsed}; +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/github-settings/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/github-settings/index.tsx new file mode 100644 index 0000000000..7ccad3569c --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/github-settings/index.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { trpc } from "@/lib/trpc/client"; +import { useProjectData } from "../../../../data-provider"; +import { GitHubConnected } from "./github-connected"; +import { GitHubNoRepo } from "./github-no-repo"; +import { ComboboxSkeleton, GitHubSettingCard, ManageGitHubAppLink } from "./shared"; + +type GitHubConnectionState = + | { status: "loading" } + | { status: "no-app"; installUrl: string } + | { status: "no-repo"; installUrl: string } + | { status: "connected"; repoFullName: string; repositoryId: number; installUrl: string }; + +export const GitHubSettings = () => { + const { projectId } = useProjectData(); + + const state = JSON.stringify({ projectId }); + const installUrl = `https://github.com/apps/${process.env.NEXT_PUBLIC_GITHUB_APP_NAME}/installations/new?state=${encodeURIComponent(state)}`; + + const { data, isLoading } = trpc.github.getInstallations.useQuery( + { projectId }, + { staleTime: 0, refetchOnWindowFocus: true }, + ); + + const connectionState: GitHubConnectionState = (() => { + if (isLoading) { + return { status: "loading" }; + } + const hasInstallations = (data?.installations?.length ?? 0) > 0; + if (!hasInstallations) { + return { status: "no-app", installUrl }; + } + const repoFullName = data?.repoConnection?.repositoryFullName; + if (repoFullName) { + const repositoryId = data?.repoConnection?.repositoryId ?? 0; + return { status: "connected", repoFullName, repositoryId, installUrl }; + } + return { status: "no-repo", installUrl }; + })(); + + switch (connectionState.status) { + case "loading": + return ( + + + + ); + // No-app means user haven't connected an app to unkey yet + case "no-app": + return ( + + + + ); + // User connected to unkey, but haven't selected a repo yet + case "no-repo": + return ; + case "connected": + return ( + + ); + } +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/github-settings/shared.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/github-settings/shared.tsx new file mode 100644 index 0000000000..2ef52f23d9 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/github-settings/shared.tsx @@ -0,0 +1,64 @@ +import { Github } from "@unkey/icons"; +import { Button, type ChevronState, SettingCard } from "@unkey/ui"; + +export const GitHubSettingCard = ({ + children, + expandable, + chevronState, +}: { + children: React.ReactNode; + expandable?: React.ReactNode; + chevronState: ChevronState; +}) => ( + } + title="Repository" + description="Source repository for this deployment" + border="top" + contentWidth="w-full lg:w-[320px] justify-end" + expandable={expandable} + chevronState={chevronState} + > + {children} + +); + +export const ComboboxSkeleton = () => ( +
+
+
+
+
+
+
+); + +export const RepoNameLabel = ({ fullName }: { fullName: string }) => { + const [handle, repoName] = fullName.split("/"); + return ( + // This max-w-[185px] and w-[185px] in ComboboxSkeleton should match +
+ {handle} + /{repoName} +
+ ); +}; + +export const ManageGitHubAppLink = ({ + installUrl, + variant = "ghost", + className = "-ml-3 px-3 py-2 rounded-lg", + text = "Manage Github App", +}: { + installUrl: string; + variant?: "outline" | "ghost"; + className?: string; + text?: React.ReactNode; +}) => ( + +); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/port-settings.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/port-settings.tsx new file mode 100644 index 0000000000..b0ed9c2b9d --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/port-settings.tsx @@ -0,0 +1,105 @@ +import { trpc } from "@/lib/trpc/client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { NumberInput } from "@unkey/icons"; +import { FormInput, toast } from "@unkey/ui"; +import { useForm, useWatch } from "react-hook-form"; +import { z } from "zod"; +import { useProjectData } from "../../../data-provider"; +import { FormSettingCard } from "../shared/form-setting-card"; + +const portSchema = z.object({ + port: z.number().int().min(2000).max(54000), +}); + +export const PortSettings = () => { + const { environments } = useProjectData(); + const environmentId = environments[0]?.id; + + const { data } = trpc.deploy.environmentSettings.get.useQuery( + { environmentId: environmentId ?? "" }, + { enabled: Boolean(environmentId) }, + ); + + const defaultValue = data?.runtimeSettings?.port ?? 8080; + return ; +}; + +const PortForm = ({ + environmentId, + defaultValue, +}: { + environmentId: string | undefined; + defaultValue: number; +}) => { + const utils = trpc.useUtils(); + + const { + register, + handleSubmit, + formState: { isValid, isSubmitting, errors }, + control, + } = useForm>({ + resolver: zodResolver(portSchema), + mode: "onChange", + defaultValues: { port: defaultValue }, + }); + + const currentPort = useWatch({ control, name: "port" }); + + const updatePort = trpc.deploy.environmentSettings.runtime.updatePort.useMutation({ + onSuccess: (_data, variables) => { + toast.success("Port updated", { + description: `Port set to ${variables.port ?? defaultValue}.`, + duration: 5000, + }); + utils.deploy.environmentSettings.get.invalidate({ environmentId }); + }, + onError: (err) => { + if (err.data?.code === "BAD_REQUEST") { + toast.error("Invalid port", { + description: err.message || "Please check your input and try again.", + }); + } else { + toast.error("Failed to update port", { + description: + err.message || + "An unexpected error occurred. Please try again or contact support@unkey.com", + action: { + label: "Contact Support", + onClick: () => window.open("mailto:support@unkey.com", "_blank"), + }, + }); + } + }, + }); + + const onSubmit = async (values: z.infer) => { + await updatePort.mutateAsync({ environmentId: environmentId ?? "", port: values.port }); + }; + + return ( + } + title="Port" + description="Port your application listens on" + displayValue={String(defaultValue)} + onSubmit={handleSubmit(onSubmit)} + canSave={isValid && !isSubmitting && currentPort !== defaultValue} + isSaving={updatePort.isLoading || isSubmitting} + > + + + ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/root-directory-settings.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/root-directory-settings.tsx new file mode 100644 index 0000000000..263d9427fd --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/root-directory-settings.tsx @@ -0,0 +1,104 @@ +import { trpc } from "@/lib/trpc/client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { FolderLink } from "@unkey/icons"; +import { FormInput, toast } from "@unkey/ui"; +import { useForm, useWatch } from "react-hook-form"; +import { z } from "zod"; +import { useProjectData } from "../../../data-provider"; +import { FormSettingCard } from "../shared/form-setting-card"; + +const rootDirectorySchema = z.object({ + dockerContext: z.string(), +}); + +export const RootDirectorySettings = () => { + const { environments } = useProjectData(); + const environmentId = environments[0]?.id; + + const { data } = trpc.deploy.environmentSettings.get.useQuery( + { environmentId: environmentId ?? "" }, + { enabled: Boolean(environmentId) }, + ); + + const defaultValue = data?.buildSettings?.dockerContext ?? "."; + return ; +}; + +const RootDirectoryForm = ({ + environmentId, + defaultValue, +}: { + environmentId: string; + defaultValue: string; +}) => { + const utils = trpc.useUtils(); + + const { + register, + handleSubmit, + formState: { isValid, isSubmitting, errors }, + control, + } = useForm>({ + resolver: zodResolver(rootDirectorySchema), + mode: "onChange", + defaultValues: { dockerContext: defaultValue }, + }); + + const currentDockerContext = useWatch({ control, name: "dockerContext" }); + + const updateDockerContext = trpc.deploy.environmentSettings.build.updateDockerContext.useMutation( + { + onSuccess: (_data, variables) => { + toast.success("Root directory updated", { + description: `Build context set to "${(variables.dockerContext ?? defaultValue) || "."}".`, + duration: 5000, + }); + utils.deploy.environmentSettings.get.invalidate({ environmentId }); + }, + onError: (err) => { + if (err.data?.code === "BAD_REQUEST") { + toast.error("Invalid root directory", { + description: err.message || "Please check your input and try again.", + }); + } else { + toast.error("Failed to update root directory", { + description: + err.message || + "An unexpected error occurred. Please try again or contact support@unkey.com", + action: { + label: "Contact Support", + onClick: () => window.open("mailto:support@unkey.com", "_blank"), + }, + }); + } + }, + }, + ); + + const onSubmit = async (values: z.infer) => { + await updateDockerContext.mutateAsync({ environmentId, dockerContext: values.dockerContext }); + }; + + return ( + } + title="Root directory" + description="Build context directory. All COPY/ADD commands are relative to this path. (e.g., services/api)" + displayValue={defaultValue || "."} + onSubmit={handleSubmit(onSubmit)} + canSave={isValid && !isSubmitting && currentDockerContext !== defaultValue} + isSaving={updateDockerContext.isLoading || isSubmitting} + > + + + ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/github-app-card.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/github-app-card.tsx deleted file mode 100644 index 2cfc1f361f..0000000000 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/github-app-card.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"use client"; - -import { Github } from "@unkey/icons"; -import { SettingCard, buttonVariants } from "@unkey/ui"; -import { useProjectData } from "../../data-provider"; - -type Props = { - hasInstallations: boolean; -}; - -export const GitHubAppCard: React.FC = ({ hasInstallations }) => { - const { projectId } = useProjectData(); - const state = JSON.stringify({ projectId }); - const installUrl = `https://github.com/apps/${process.env.NEXT_PUBLIC_GITHUB_APP_NAME}/installations/new?state=${encodeURIComponent(state)}`; - - return ( - - - - ); -}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/github-settings-client.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/github-settings-client.tsx deleted file mode 100644 index 7805fc87f4..0000000000 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/github-settings-client.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -import { trpc } from "@/lib/trpc/client"; -import { Loading, toast } from "@unkey/ui"; -import { useProjectData } from "../../data-provider"; -import { GitHubAppCard } from "./github-app-card"; -import { RepositoryCard } from "./repository-card"; - -export const GitHubSettingsClient: React.FC = () => { - const { projectId } = useProjectData(); - const utils = trpc.useUtils(); - - const { data, isLoading, refetch } = trpc.github.getInstallations.useQuery( - { projectId }, - { - staleTime: 0, - refetchOnWindowFocus: true, - }, - ); - - const disconnectRepoMutation = trpc.github.disconnectRepo.useMutation({ - onSuccess: async () => { - toast.success("Repository disconnected"); - await utils.github.getInstallations.invalidate(); - await refetch(); - }, - onError: (error) => { - toast.error(error.message); - }, - }); - - if (isLoading) { - return ( -
- -
- ); - } - - const hasInstallations = (data?.installations?.length ?? 0) > 0; - const repoConnection = data?.repoConnection; - - return ( -
- {hasInstallations ? ( - <> - - disconnectRepoMutation.mutate({ projectId })} - isDisconnecting={disconnectRepoMutation.isLoading} - /> - - ) : ( - - )} -
- ); -}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/repository-card.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/repository-card.tsx deleted file mode 100644 index 3f81570e0d..0000000000 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/repository-card.tsx +++ /dev/null @@ -1,125 +0,0 @@ -"use client"; - -import { RepoDisplay } from "@/app/(app)/[workspaceSlug]/projects/_components/list/repo-display"; -import { Combobox } from "@/components/ui/combobox"; -import { trpc } from "@/lib/trpc/client"; -import { Button, SettingCard, toast } from "@unkey/ui"; -import { useMemo, useState } from "react"; -import { useProjectData } from "../../data-provider"; - -type Props = { - connectedRepo: string | null; - onDisconnect: () => void; - isDisconnecting: boolean; -}; - -export const RepositoryCard: React.FC = ({ - connectedRepo, - onDisconnect, - isDisconnecting, -}) => { - const { projectId } = useProjectData(); - const utils = trpc.useUtils(); - const [selectedRepo, setSelectedRepo] = useState(""); - - const { data: reposData, isLoading: isLoadingRepos } = trpc.github.listRepositories.useQuery( - { projectId }, - { - enabled: !connectedRepo, - }, - ); - - const selectRepoMutation = trpc.github.selectRepository.useMutation({ - onSuccess: async () => { - toast.success("Repository connected"); - await utils.github.getInstallations.invalidate(); - }, - onError: (error) => { - toast.error(error.message); - }, - }); - - const repoOptions = useMemo( - () => - (reposData?.repositories ?? []).map((repo) => ({ - value: `${repo.installationId}:${repo.id}`, - label: repo.fullName, - searchValue: repo.fullName, - })), - [reposData?.repositories], - ); - - const handleSelectRepository = (value: string) => { - setSelectedRepo(value); - const repo = reposData?.repositories.find((r) => `${r.installationId}:${r.id}` === value); - if (!repo) { - return; - } - selectRepoMutation.mutate({ - projectId, - repositoryId: repo.id, - repositoryFullName: repo.fullName, - installationId: repo.installationId, - }); - }; - - if (connectedRepo) { - return ( - - - - Pushes to this repository will trigger deployments. - -
- } - border="bottom" - contentWidth="w-full lg:w-[420px] h-full justify-end items-end" - > -
- -
- - ); - } - - return ( - - Select a repository to connect to this project. - - Pushes to this repository will trigger deployments. - -
- } - border="bottom" - contentWidth="w-full lg:w-[420px] h-full justify-end items-end" - > -
- {isLoadingRepos ? ( -
- ) : repoOptions.length ? ( - - ) : ( - No repositories found. - )} -
- - ); -}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-application-settings.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-application-settings.tsx deleted file mode 100644 index 6f5cc97308..0000000000 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-application-settings.tsx +++ /dev/null @@ -1,287 +0,0 @@ -"use client"; - -import { trpc } from "@/lib/trpc/client"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { - Button, - FormInput, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - SettingCard, - toast, -} from "@unkey/ui"; -import { useForm, useWatch } from "react-hook-form"; -import { z } from "zod"; - -type Props = { - environmentId: string; -}; - -const portSchema = z.object({ - port: z.number().min(2000).max(54000), -}); - -const commandSchema = z.object({ - command: z.string(), -}); - -const healthcheckSchema = z.object({ - method: z.enum(["GET", "POST"]), - path: z.string(), -}); - -const PortCard: React.FC = ({ environmentId, defaultPort }) => { - const utils = trpc.useUtils(); - - const { - register, - handleSubmit, - formState: { errors, isValid, isSubmitting }, - control, - } = useForm>({ - resolver: zodResolver(portSchema), - mode: "onChange", - defaultValues: { port: defaultPort }, - }); - - const currentPort = useWatch({ control, name: "port" }); - - const updateRuntime = trpc.deploy.environmentSettings.updateRuntime.useMutation({ - onSuccess: () => { - toast.success("Port updated"); - utils.deploy.environmentSettings.get.invalidate({ environmentId }); - }, - onError: (err) => { - toast.error("Failed to update port", { - description: err.message, - }); - }, - }); - - const onSubmit = async (values: z.infer) => { - await updateRuntime.mutateAsync({ environmentId, port: values.port }); - }; - - return ( -
- -
- - -
-
-
- ); -}; - -const CommandCard: React.FC = ({ - environmentId, - defaultCommand, -}) => { - const utils = trpc.useUtils(); - - const { - register, - handleSubmit, - formState: { isValid, isSubmitting }, - control, - } = useForm>({ - resolver: zodResolver(commandSchema), - mode: "onChange", - defaultValues: { command: defaultCommand }, - }); - - const currentCommand = useWatch({ control, name: "command" }); - - const updateRuntime = trpc.deploy.environmentSettings.updateRuntime.useMutation({ - onSuccess: () => { - toast.success("Command updated"); - utils.deploy.environmentSettings.get.invalidate({ environmentId }); - }, - onError: (err) => { - toast.error("Failed to update command", { - description: err.message, - }); - }, - }); - - const onSubmit = async (values: z.infer) => { - const trimmed = values.command.trim(); - const command = trimmed === "" ? [] : trimmed.split(/\s+/).filter(Boolean); - await updateRuntime.mutateAsync({ environmentId, command }); - }; - - return ( -
- -
- - -
-
-
- ); -}; - -const HealthcheckCard: React.FC = ({ - environmentId, - defaultMethod, - defaultPath, -}) => { - const utils = trpc.useUtils(); - - const { - register, - handleSubmit, - formState: { isValid, isSubmitting }, - setValue, - control, - } = useForm>({ - resolver: zodResolver(healthcheckSchema), - mode: "onChange", - defaultValues: { method: defaultMethod, path: defaultPath }, - }); - - const currentMethod = useWatch({ control, name: "method" }); - const currentPath = useWatch({ control, name: "path" }); - - const updateRuntime = trpc.deploy.environmentSettings.updateRuntime.useMutation({ - onSuccess: () => { - toast.success("Healthcheck updated"); - utils.deploy.environmentSettings.get.invalidate({ environmentId }); - }, - onError: (err) => { - toast.error("Failed to update healthcheck", { - description: err.message, - }); - }, - }); - - const onSubmit = async (values: z.infer) => { - const path = values.path.trim(); - await updateRuntime.mutateAsync({ - environmentId, - healthcheck: - path === "" - ? null - : { - method: values.method, - path, - intervalSeconds: 10, - timeoutSeconds: 5, - failureThreshold: 3, - initialDelaySeconds: 0, - }, - }); - }; - - const hasChanged = currentMethod !== defaultMethod || currentPath !== defaultPath; - - return ( -
- -
- - - -
-
-
- ); -}; - -export const RuntimeApplicationSettings: React.FC = ({ environmentId }) => { - const { data } = trpc.deploy.environmentSettings.get.useQuery({ environmentId }); - const runtimeSettings = data?.runtimeSettings; - - return ( -
- - - -
- ); -}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-scaling-settings.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-scaling-settings.tsx deleted file mode 100644 index ad4bfbdc40..0000000000 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-scaling-settings.tsx +++ /dev/null @@ -1,302 +0,0 @@ -"use client"; - -import { trpc } from "@/lib/trpc/client"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { - Button, - FormInput, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - SettingCard, - toast, -} from "@unkey/ui"; -import { useForm, useWatch } from "react-hook-form"; -import { z } from "zod"; - -type Props = { - environmentId: string; -}; - -const CPU_OPTIONS = [ - { label: "0.25 vCPU", value: 256, disabled: false }, - { label: "0.5 vCPU", value: 512, disabled: false }, - { label: "1 vCPU", value: 1024, disabled: false }, - { label: "2 vCPU", value: 2048, disabled: false }, - { label: "4 vCPU", value: 4096, disabled: false }, - { label: "8 vCPU", value: 8192, disabled: true }, - { label: "16 vCPU", value: 16384, disabled: true }, - { label: "32 vCPU", value: 32768, disabled: true }, -] as const; - -const MEMORY_OPTIONS = [ - { label: "256 MB", value: 256, disabled: false }, - { label: "512 MB", value: 512, disabled: false }, - { label: "1 GB", value: 1024, disabled: false }, - { label: "2 GB", value: 2048, disabled: false }, - { label: "4 GB", value: 4096, disabled: false }, - { label: "8 GB", value: 8192, disabled: true }, - { label: "16 GB", value: 16384, disabled: true }, - { label: "32 GB", value: 32768, disabled: true }, -] as const; - -const replicasSchema = z.object({ - replicas: z.number().min(1).max(10), -}); - -const cpuSchema = z.object({ cpu: z.number() }); -const memorySchema = z.object({ memory: z.number() }); - -const CpuCard: React.FC = ({ environmentId, defaultCpu }) => { - const utils = trpc.useUtils(); - - const { - handleSubmit, - formState: { isValid, isSubmitting }, - setValue, - control, - } = useForm>({ - resolver: zodResolver(cpuSchema), - mode: "onChange", - defaultValues: { cpu: defaultCpu }, - }); - - const currentCpu = useWatch({ control, name: "cpu" }); - - const updateRuntime = trpc.deploy.environmentSettings.updateRuntime.useMutation({ - onSuccess: () => { - toast.success("CPU updated"); - utils.deploy.environmentSettings.get.invalidate({ environmentId }); - }, - onError: (err) => { - toast.error("Failed to update CPU", { description: err.message }); - }, - }); - - const onSubmit = async (values: z.infer) => { - await updateRuntime.mutateAsync({ - environmentId, - cpuMillicores: values.cpu, - }); - }; - - return ( -
- -
- - -
-
-
- ); -}; - -const MemoryCard: React.FC = ({ - environmentId, - defaultMemory, -}) => { - const utils = trpc.useUtils(); - - const { - handleSubmit, - formState: { isValid, isSubmitting }, - setValue, - control, - } = useForm>({ - resolver: zodResolver(memorySchema), - mode: "onChange", - defaultValues: { memory: defaultMemory }, - }); - - const currentMemory = useWatch({ control, name: "memory" }); - - const updateRuntime = trpc.deploy.environmentSettings.updateRuntime.useMutation({ - onSuccess: () => { - toast.success("Memory updated"); - utils.deploy.environmentSettings.get.invalidate({ environmentId }); - }, - onError: (err) => { - toast.error("Failed to update memory", { description: err.message }); - }, - }); - - const onSubmit = async (values: z.infer) => { - await updateRuntime.mutateAsync({ - environmentId, - memoryMib: values.memory, - }); - }; - - return ( -
- -
- - -
-
-
- ); -}; - -const ReplicasCard: React.FC = ({ - environmentId, - defaultReplicas, -}) => { - const utils = trpc.useUtils(); - - const { - register, - handleSubmit, - formState: { errors, isValid, isSubmitting }, - control, - } = useForm>({ - resolver: zodResolver(replicasSchema), - mode: "onChange", - defaultValues: { replicas: defaultReplicas }, - }); - - const currentReplicas = useWatch({ control, name: "replicas" }); - - const updateRuntime = trpc.deploy.environmentSettings.updateRuntime.useMutation({ - onSuccess: () => { - toast.success("Replicas updated"); - utils.deploy.environmentSettings.get.invalidate({ environmentId }); - }, - onError: (err) => { - toast.error("Failed to update replicas", { - description: err.message, - }); - }, - }); - - const onSubmit = async (values: z.infer) => { - await updateRuntime.mutateAsync({ - environmentId, - replicasPerRegion: values.replicas, - }); - }; - - return ( -
- -
- - -
-
-
- ); -}; - -export const RuntimeScalingSettings: React.FC = ({ environmentId }) => { - const { data } = trpc.deploy.environmentSettings.get.useQuery({ environmentId }); - const runtimeSettings = data?.runtimeSettings; - - return ( -
- - - ) ?? {})[0] ?? 1 - } - /> -
- ); -}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/cpu.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/cpu.tsx new file mode 100644 index 0000000000..17d5fb245a --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/cpu.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { trpc } from "@/lib/trpc/client"; +import { formatCpu } from "@/lib/utils/deployment-formatters"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Bolt } from "@unkey/icons"; +import { Slider, toast } from "@unkey/ui"; +import { useEffect } from "react"; +import { useForm, useWatch } from "react-hook-form"; +import { z } from "zod"; +import { useProjectData } from "../../../data-provider"; +import { FormSettingCard } from "../shared/form-setting-card"; +import { SettingDescription } from "../shared/setting-description"; +import { indexToValue, valueToIndex } from "../shared/slider-utils"; + +const CPU_OPTIONS = [ + { label: "1/4 vCPU", value: 256 }, + { label: "1/2 vCPU", value: 512 }, + { label: "1 vCPU", value: 1024 }, + { label: "2 vCPU", value: 2048 }, + { label: "4 vCPU", value: 4096 }, + { label: "8 vCPU", value: 8192 }, + { label: "16 vCPU", value: 16384 }, + { label: "32 vCPU", value: 32768 }, +] as const; + +const cpuSchema = z.object({ + cpu: z.number(), +}); + +type CpuFormValues = z.infer; + +export const Cpu = () => { + const { environments } = useProjectData(); + const environmentId = environments[0]?.id; + + const { data: settingsData } = trpc.deploy.environmentSettings.get.useQuery( + { environmentId: environmentId ?? "" }, + { enabled: Boolean(environmentId) }, + ); + + const defaultCpu = settingsData?.runtimeSettings?.cpuMillicores ?? 256; + + return ; +}; + +type CpuFormProps = { + environmentId: string; + defaultCpu: number; +}; + +const CpuForm: React.FC = ({ environmentId, defaultCpu }) => { + const utils = trpc.useUtils(); + + const { + handleSubmit, + setValue, + formState: { isValid, isSubmitting }, + control, + reset, + } = useForm({ + resolver: zodResolver(cpuSchema), + mode: "onChange", + defaultValues: { cpu: defaultCpu }, + }); + + useEffect(() => { + reset({ cpu: defaultCpu }); + }, [defaultCpu, reset]); + + const currentCpu = useWatch({ control, name: "cpu" }); + + const updateCpu = trpc.deploy.environmentSettings.runtime.updateCpu.useMutation({ + onSuccess: (_data, variables) => { + toast.success("CPU updated", { + description: `CPU set to ${formatCpu(variables.cpuMillicores ?? defaultCpu)}`, + duration: 5000, + }); + utils.deploy.environmentSettings.get.invalidate({ environmentId }); + }, + onError: (err) => { + if (err.data?.code === "BAD_REQUEST") { + toast.error("Invalid CPU setting", { + description: err.message || "Please check your input and try again.", + }); + } else { + toast.error("Failed to update CPU", { + description: + err.message || + "An unexpected error occurred. Please try again or contact support@unkey.com", + action: { + label: "Contact Support", + onClick: () => window.open("mailto:support@unkey.com", "_blank"), + }, + }); + } + }, + }); + + const onSubmit = async (values: CpuFormValues) => { + await updateCpu.mutateAsync({ + environmentId, + cpuMillicores: values.cpu, + }); + }; + + const hasChanges = currentCpu !== defaultCpu; + const currentIndex = valueToIndex(CPU_OPTIONS, currentCpu); + + return ( + } + title="CPU" + description="CPU allocation for each instance" + displayValue={(() => { + const [value, unit] = parseCpuDisplay(defaultCpu); + return ( +
+ {value} + {unit} +
+ ); + })()} + onSubmit={handleSubmit(onSubmit)} + canSave={isValid && !isSubmitting && hasChanges} + isSaving={updateCpu.isLoading || isSubmitting} + > +
+ CPU per instance +
+ { + if (value !== undefined) { + setValue("cpu", indexToValue(CPU_OPTIONS, value, 256), { shouldValidate: true }); + } + }} + className="flex-1 max-w-[480px]" + rangeStyle={{ + background: "linear-gradient(to right, hsla(var(--infoA-4)), hsla(var(--infoA-12)))", + backgroundSize: `${currentIndex > 0 ? 100 / (currentIndex / (CPU_OPTIONS.length - 1)) : 100}% 100%`, + backgroundRepeat: "no-repeat", + }} + /> + + {formatCpu(currentCpu)} + +
+ + Higher CPU improves compute-heavy workloads. Changes apply on next deploy. + +
+
+ ); +}; + +function parseCpuDisplay(millicores: number): [string, string] { + if (millicores === 256) { + return ["1/4", "vCPU"]; + } + if (millicores === 512) { + return ["1/2", "vCPU"]; + } + if (millicores === 768) { + return ["3/4", "vCPU"]; + } + if (millicores >= 1024 && millicores % 1024 === 0) { + return [`${millicores / 1024}`, "vCPU"]; + } + return [`${millicores}m`, "vCPU"]; +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/healthcheck/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/healthcheck/index.tsx new file mode 100644 index 0000000000..182d491e19 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/healthcheck/index.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { trpc } from "@/lib/trpc/client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { ChevronDown, HeartPulse } from "@unkey/icons"; +import { + FormInput, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + toast, +} from "@unkey/ui"; +import { useEffect } from "react"; +import { Controller, useForm, useWatch } from "react-hook-form"; +import { useProjectData } from "../../../../data-provider"; +import { FormSettingCard } from "../../shared/form-setting-card"; +import { MethodBadge } from "./method-badge"; +import { HTTP_METHODS, type HealthcheckFormValues, healthcheckSchema } from "./schema"; +import { intervalToSeconds, secondsToInterval } from "./utils"; + +export const Healthcheck = () => { + const { environments } = useProjectData(); + const environmentId = environments[0]?.id; + + const { data: settingsData } = trpc.deploy.environmentSettings.get.useQuery( + { environmentId: environmentId ?? "" }, + { enabled: Boolean(environmentId) }, + ); + + const healthcheck = settingsData?.runtimeSettings?.healthcheck; + const defaultValues: HealthcheckFormValues = { + method: healthcheck?.method ?? "GET", + path: healthcheck?.path ?? "/health", + interval: healthcheck ? secondsToInterval(healthcheck.intervalSeconds) : "30s", + }; + + return ; +}; + +type HealthcheckFormProps = { + environmentId: string; + defaultValues: HealthcheckFormValues; +}; + +const HealthcheckForm: React.FC = ({ environmentId, defaultValues }) => { + const utils = trpc.useUtils(); + + const { + handleSubmit, + control, + register, + reset, + formState: { isValid, isSubmitting, errors }, + } = useForm({ + resolver: zodResolver(healthcheckSchema), + mode: "onChange", + defaultValues, + }); + + // biome-ignore lint/correctness/useExhaustiveDependencies: we gucci + useEffect(() => { + reset(defaultValues); + }, [defaultValues.method, defaultValues.path, defaultValues.interval, reset]); + + const currentMethod = useWatch({ control, name: "method" }); + const currentPath = useWatch({ control, name: "path" }); + const currentInterval = useWatch({ control, name: "interval" }); + + const updateHealthcheck = trpc.deploy.environmentSettings.runtime.updateHealthcheck.useMutation({ + onSuccess: () => { + toast.success("Healthcheck updated", { duration: 5000 }); + utils.deploy.environmentSettings.get.invalidate({ environmentId }); + }, + onError: (err) => { + if (err.data?.code === "BAD_REQUEST") { + toast.error("Invalid healthcheck setting", { + description: err.message || "Please check your input and try again.", + }); + } else { + toast.error("Failed to update healthcheck", { + description: + err.message || + "An unexpected error occurred. Please try again or contact support@unkey.com", + action: { + label: "Contact Support", + onClick: () => window.open("mailto:support@unkey.com", "_blank"), + }, + }); + } + }, + }); + + const onSubmit = async (values: HealthcheckFormValues) => { + await updateHealthcheck.mutateAsync({ + environmentId, + healthcheck: + values.path.trim() === "" + ? null + : { + method: values.method, + path: values.path.trim(), + intervalSeconds: intervalToSeconds(values.interval), + timeoutSeconds: 5, + failureThreshold: 3, + initialDelaySeconds: 0, + }, + }); + }; + + const hasChanges = + currentMethod !== defaultValues.method || + currentPath !== defaultValues.path || + currentInterval !== defaultValues.interval; + + return ( + } + title="Healthcheck" + description="Endpoint used to verify the service is healthy" + displayValue={ +
+ + {defaultValues.path} + every {defaultValues.interval} +
+ } + onSubmit={handleSubmit(onSubmit)} + canSave={isValid && !isSubmitting && hasChanges} + isSaving={updateHealthcheck.isLoading || isSubmitting} + > +
+ {/* TODO: multi-check when API supports + {fields.map((field, index) => ( +
+ ... add/remove buttons and per-entry fields ... +
+ ))} + */} +
+ Method + Path + Interval +
+
+ ( + + )} + /> + + +
+
+
+ ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/healthcheck/method-badge.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/healthcheck/method-badge.tsx new file mode 100644 index 0000000000..bcd4a7da38 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/healthcheck/method-badge.tsx @@ -0,0 +1,28 @@ +import { Badge } from "@unkey/ui"; + +function getMethodVariant(method: string): "success" | "warning" | "error" | "primary" | "blocked" { + switch (method) { + case "GET": + case "HEAD": + return "success"; + case "POST": + return "warning"; + case "PUT": + case "PATCH": + return "blocked"; + case "DELETE": + return "error"; + default: + return "primary"; + } +} + +export const MethodBadge: React.FC<{ method: string }> = ({ method }) => ( + + {method} + +); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/healthcheck/schema.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/healthcheck/schema.ts new file mode 100644 index 0000000000..ee4d304de3 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/healthcheck/schema.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +// TODO: extend when API supports more methods +export const HTTP_METHODS = ["GET", "POST"] as const; + +export const INTERVAL_REGEX = /^\d+[smh]$/; + +// TODO: MAX_CHECKS = 3 and array schema for multi-check when API supports +export const healthcheckSchema = z.object({ + method: z.enum(["GET", "POST"]), + path: z + .string() + .min(1, "Path is required") + .startsWith("/", "Path must start with /") + .regex(/^\/[\w\-./]*$/, "Invalid path characters"), + interval: z + .string() + .min(1, "Interval is required") + .regex(INTERVAL_REGEX, "Use format like 15s, 2m, or 1h"), +}); + +export type HealthcheckFormValues = z.infer; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/healthcheck/utils.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/healthcheck/utils.ts new file mode 100644 index 0000000000..1565d94b67 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/healthcheck/utils.ts @@ -0,0 +1,20 @@ +export function intervalToSeconds(interval: string): number { + const num = Number.parseInt(interval, 10); + if (interval.endsWith("h")) { + return num * 3600; + } + if (interval.endsWith("m")) { + return num * 60; + } + return num; +} + +export function secondsToInterval(seconds: number): string { + if (seconds % 3600 === 0) { + return `${seconds / 3600}h`; + } + if (seconds % 60 === 0) { + return `${seconds / 60}m`; + } + return `${seconds}s`; +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/instances.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/instances.tsx new file mode 100644 index 0000000000..345fd514c6 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/instances.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { trpc } from "@/lib/trpc/client"; +import { mapRegionToFlag } from "@/lib/trpc/routers/deploy/network/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Connections3 } from "@unkey/icons"; +import { Slider, toast } from "@unkey/ui"; +import { useEffect } from "react"; +import { useForm, useWatch } from "react-hook-form"; +import { z } from "zod"; +import { RegionFlag } from "../../../../components/region-flag"; +import { useProjectData } from "../../../data-provider"; +import { FormSettingCard } from "../shared/form-setting-card"; +import { SettingDescription } from "../shared/setting-description"; + +const instancesSchema = z.object({ + instances: z.number().min(1).max(10), +}); + +type InstancesFormValues = z.infer; + +export const Instances = () => { + const { environments } = useProjectData(); + const environmentId = environments[0]?.id; + + const { data: settingsData } = trpc.deploy.environmentSettings.get.useQuery( + { environmentId: environmentId ?? "" }, + { enabled: Boolean(environmentId) }, + ); + + const regionConfig = + (settingsData?.runtimeSettings?.regionConfig as Record) ?? {}; + const selectedRegions = Object.keys(regionConfig); + const defaultInstances = Object.values(regionConfig)[0] ?? 1; + + return ( + + ); +}; + +type InstancesFormProps = { + environmentId: string; + defaultInstances: number; + selectedRegions: string[]; +}; + +const InstancesForm: React.FC = ({ + environmentId, + defaultInstances, + selectedRegions, +}) => { + const utils = trpc.useUtils(); + + const { + handleSubmit, + setValue, + formState: { isValid, isSubmitting }, + control, + reset, + } = useForm({ + resolver: zodResolver(instancesSchema), + mode: "onChange", + defaultValues: { instances: defaultInstances }, + }); + + useEffect(() => { + reset({ instances: defaultInstances }); + }, [defaultInstances, reset]); + + const currentInstances = useWatch({ control, name: "instances" }); + + const updateInstances = trpc.deploy.environmentSettings.runtime.updateInstances.useMutation({ + onSuccess: (_data, variables) => { + const count = variables.replicasPerRegion ?? defaultInstances; + toast.success("Instances updated", { + description: `Set to ${count} instance${count !== 1 ? "s" : ""} per region.`, + duration: 5000, + }); + utils.deploy.environmentSettings.get.invalidate({ environmentId }); + }, + onError: (err) => { + if (err.data?.code === "BAD_REQUEST") { + toast.error("Invalid instances setting", { + description: err.message || "Please check your input and try again.", + }); + } else { + toast.error("Failed to update instances", { + description: + err.message || + "An unexpected error occurred. Please try again or contact support@unkey.com", + action: { + label: "Contact Support", + onClick: () => window.open("mailto:support@unkey.com", "_blank"), + }, + }); + } + }, + }); + + const onSubmit = async (values: InstancesFormValues) => { + await updateInstances.mutateAsync({ + environmentId, + replicasPerRegion: values.instances, + }); + }; + + const hasChanges = currentInstances !== defaultInstances; + + return ( + } + title="Instances" + description="Number of instances running in each region" + displayValue={ +
+ {defaultInstances} + + instance{defaultInstances !== 1 ? "s" : ""} + +
+ } + onSubmit={handleSubmit(onSubmit)} + canSave={isValid && !isSubmitting && hasChanges} + isSaving={updateInstances.isLoading || isSubmitting} + > +
+ Instances per region +
+ { + if (value !== undefined) { + setValue("instances", value, { shouldValidate: true }); + } + }} + className="flex-1 max-w-[480px]" + rangeStyle={{ + background: + "linear-gradient(to right, hsla(var(--featureA-4)), hsla(var(--featureA-12)))", + backgroundSize: `${currentInstances > 1 ? 100 / ((currentInstances - 1) / 9) : 100}% 100%`, + backgroundRepeat: "no-repeat", + }} + /> +
+ {selectedRegions.map((r) => ( + + ))} +
+ + {currentInstances}{" "} + + instance{currentInstances !== 1 ? "s" : ""} + + +
+ + More instances improve availability and handle higher traffic. Changes apply on next + deploy. + +
+
+ ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/memory.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/memory.tsx new file mode 100644 index 0000000000..e04b974649 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/memory.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { trpc } from "@/lib/trpc/client"; +import { formatMemory } from "@/lib/utils/deployment-formatters"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { ScanCode } from "@unkey/icons"; +import { Slider, toast } from "@unkey/ui"; +import { useEffect } from "react"; +import { useForm, useWatch } from "react-hook-form"; +import { z } from "zod"; +import { useProjectData } from "../../../data-provider"; +import { FormSettingCard } from "../shared/form-setting-card"; +import { SettingDescription } from "../shared/setting-description"; +import { indexToValue, valueToIndex } from "../shared/slider-utils"; + +const MEMORY_OPTIONS = [ + { label: "256 MiB", value: 256 }, + { label: "512 MiB", value: 512 }, + { label: "1 GiB", value: 1024 }, + { label: "2 GiB", value: 2048 }, + { label: "4 GiB", value: 4096 }, + { label: "8 GiB", value: 8192 }, + { label: "16 GiB", value: 16384 }, + { label: "32 GiB", value: 32768 }, +] as const; + +const memorySchema = z.object({ + memory: z.number(), +}); + +type MemoryFormValues = z.infer; + +export const Memory = () => { + const { environments } = useProjectData(); + const environmentId = environments[0]?.id; + + const { data: settingsData } = trpc.deploy.environmentSettings.get.useQuery( + { environmentId: environmentId ?? "" }, + { enabled: Boolean(environmentId) }, + ); + + const defaultMemory = settingsData?.runtimeSettings?.memoryMib ?? 256; + + return ; +}; + +type MemoryFormProps = { + environmentId: string; + defaultMemory: number; +}; + +const MemoryForm: React.FC = ({ environmentId, defaultMemory }) => { + const utils = trpc.useUtils(); + + const { + handleSubmit, + setValue, + formState: { isValid, isSubmitting }, + control, + reset, + } = useForm({ + resolver: zodResolver(memorySchema), + mode: "onChange", + defaultValues: { memory: defaultMemory }, + }); + + useEffect(() => { + reset({ memory: defaultMemory }); + }, [defaultMemory, reset]); + + const currentMemory = useWatch({ control, name: "memory" }); + + const updateMemory = trpc.deploy.environmentSettings.runtime.updateMemory.useMutation({ + onSuccess: (_data, variables) => { + toast.success("Memory updated", { + description: `Memory set to ${formatMemory(variables.memoryMib ?? defaultMemory)}`, + duration: 5000, + }); + utils.deploy.environmentSettings.get.invalidate({ environmentId }); + }, + onError: (err) => { + if (err.data?.code === "BAD_REQUEST") { + toast.error("Invalid memory setting", { + description: err.message || "Please check your input and try again.", + }); + } else { + toast.error("Failed to update memory", { + description: + err.message || + "An unexpected error occurred. Please try again or contact support@unkey.com", + action: { + label: "Contact Support", + onClick: () => window.open("mailto:support@unkey.com", "_blank"), + }, + }); + } + }, + }); + + const onSubmit = async (values: MemoryFormValues) => { + await updateMemory.mutateAsync({ + environmentId, + memoryMib: values.memory, + }); + }; + + const hasChanges = currentMemory !== defaultMemory; + const currentIndex = valueToIndex(MEMORY_OPTIONS, currentMemory); + + return ( + } + title="Memory" + description="Memory allocation for each instance" + displayValue={(() => { + const [value, unit] = parseMemoryDisplay(defaultMemory); + return ( +
+ {value} + {unit} +
+ ); + })()} + onSubmit={handleSubmit(onSubmit)} + canSave={isValid && !isSubmitting && hasChanges} + isSaving={updateMemory.isLoading || isSubmitting} + > +
+ Memory per instance +
+ { + if (value !== undefined) { + setValue("memory", indexToValue(MEMORY_OPTIONS, value, 256), { + shouldValidate: true, + }); + } + }} + className="flex-1 max-w-[480px]" + rangeStyle={{ + background: + "linear-gradient(to right, hsla(var(--warningA-4)), hsla(var(--warningA-12)))", + backgroundSize: `${currentIndex > 0 ? 100 / (currentIndex / (MEMORY_OPTIONS.length - 1)) : 100}% 100%`, + backgroundRepeat: "no-repeat", + }} + /> + + {formatMemory(currentMemory)} + +
+ + Increase memory for applications with large datasets or caching needs. Changes apply on + next deploy. + +
+
+ ); +}; + +function parseMemoryDisplay(mib: number): [string, string] { + if (mib >= 1024) { + return [`${(mib / 1024).toFixed(mib % 1024 === 0 ? 0 : 1)}`, "GiB"]; + } + return [`${mib}`, "MiB"]; +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/regions.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/regions.tsx new file mode 100644 index 0000000000..2d1e756d75 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/regions.tsx @@ -0,0 +1,231 @@ +"use client"; + +import type { ComboboxOption } from "@/components/ui/combobox"; +import { FormCombobox } from "@/components/ui/form-combobox"; +import { trpc } from "@/lib/trpc/client"; +import { mapRegionToFlag } from "@/lib/trpc/routers/deploy/network/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Location2, XMark } from "@unkey/icons"; +import { toast } from "@unkey/ui"; +import { useEffect } from "react"; +import { useForm, useWatch } from "react-hook-form"; +import { z } from "zod"; +import { RegionFlag } from "../../../../components/region-flag"; +import { useProjectData } from "../../../data-provider"; +import { FormSettingCard } from "../shared/form-setting-card"; + +const regionsSchema = z.object({ + regions: z.array(z.string()).min(1, "Select at least one region"), +}); + +type RegionsFormValues = z.infer; + +export const Regions = () => { + const { environments } = useProjectData(); + const environmentId = environments[0]?.id; + + const { data: settingsData } = trpc.deploy.environmentSettings.get.useQuery( + { environmentId: environmentId ?? "" }, + { enabled: Boolean(environmentId) }, + ); + + const { data: availableRegions } = trpc.deploy.environmentSettings.getAvailableRegions.useQuery( + undefined, + { enabled: Boolean(environmentId) }, + ); + + const regionConfig = + (settingsData?.runtimeSettings?.regionConfig as Record) ?? {}; + const defaultRegions = Object.keys(regionConfig); + + return ( + + ); +}; + +type RegionsFormProps = { + environmentId: string; + defaultRegions: string[]; + availableRegions: string[]; +}; + +const RegionsForm: React.FC = ({ + environmentId, + defaultRegions, + availableRegions, +}) => { + const utils = trpc.useUtils(); + + const { + handleSubmit, + setValue, + formState: { isValid, isSubmitting }, + control, + reset, + } = useForm({ + resolver: zodResolver(regionsSchema), + mode: "onChange", + defaultValues: { regions: defaultRegions }, + }); + + useEffect(() => { + reset({ regions: defaultRegions }); + }, [defaultRegions, reset]); + + const currentRegions = useWatch({ control, name: "regions" }); + + const unselectedRegions = availableRegions.filter((r) => !currentRegions.includes(r)); + + const updateRegions = trpc.deploy.environmentSettings.runtime.updateRegions.useMutation({ + onSuccess: () => { + toast.success("Regions updated", { + description: "Deployment regions saved successfully.", + duration: 5000, + }); + utils.deploy.environmentSettings.get.invalidate({ environmentId }); + }, + onError: (err) => { + if (err.data?.code === "BAD_REQUEST") { + toast.error("Invalid regions setting", { + description: err.message || "Please check your input and try again.", + }); + } else { + toast.error("Failed to update regions", { + description: + err.message || + "An unexpected error occurred. Please try again or contact support@unkey.com", + action: { + label: "Contact Support", + onClick: () => window.open("mailto:support@unkey.com", "_blank"), + }, + }); + } + }, + }); + + const onSubmit = async (values: RegionsFormValues) => { + await updateRegions.mutateAsync({ + environmentId, + regions: values.regions, + }); + }; + + const addRegion = (region: string) => { + if (region && !currentRegions.includes(region)) { + setValue("regions", [...currentRegions, region], { shouldValidate: true }); + } + }; + + const removeRegion = (region: string) => { + setValue( + "regions", + currentRegions.filter((r) => r !== region), + { shouldValidate: true }, + ); + }; + + const hasChanges = + currentRegions.length !== defaultRegions.length || + currentRegions.some((r) => !defaultRegions.includes(r)); + + const displayValue = + defaultRegions.length === 0 ? ( + "No regions selected" + ) : defaultRegions.length <= 2 ? ( + + {defaultRegions.map((r, i) => ( + + {i > 0 && |} + + + {r} + + + ))} + + ) : ( + + {defaultRegions.map((r) => ( + + ))} + + ); + + const comboboxOptions: ComboboxOption[] = unselectedRegions.map((region) => ({ + value: region, + searchValue: region, + label: ( +
+ + {region} +
+ ), + })); + + return ( + } + title="Regions" + description="Geographic regions where your project will run" + displayValue={displayValue} + onSubmit={handleSubmit(onSubmit)} + canSave={isValid && !isSubmitting && hasChanges} + isSaving={updateRegions.isLoading || isSubmitting} + > + Select a region + ) : ( +
+ {currentRegions.map((r) => ( + + + {r} + {currentRegions.length > 1 && ( + //biome-ignore lint/a11y/useKeyWithClickEvents: we can't use button here otherwise we'll nest two buttons + { + e.stopPropagation(); + removeRegion(r); + }} + className="p-0.5 hover:bg-grayA-4 rounded text-grayA-9 hover:text-accent-12 transition-colors" + > + + + )} + + ))} +
+ ) + } + searchPlaceholder="Search regions..." + emptyMessage={
No regions available.
} + /> +
+ ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/scaling.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/scaling.tsx new file mode 100644 index 0000000000..82338e54da --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/scaling.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Gauge } from "@unkey/icons"; +import { Slider } from "@unkey/ui"; +import { useForm, useWatch } from "react-hook-form"; +import { z } from "zod"; +import { FormSettingCard } from "../shared/form-setting-card"; +import { SettingDescription } from "../shared/setting-description"; + +const scalingSchema = z + .object({ + minInstances: z.number().min(1).max(20), + maxInstances: z.number().min(1).max(20), + cpuThreshold: z.number().min(10).max(100), + }) + .refine((d) => d.maxInstances >= d.minInstances, { + message: "Max must be ≥ min", + path: ["maxInstances"], + }); + +type ScalingFormValues = z.infer; + +const DEFAULT_VALUES: ScalingFormValues = { + minInstances: 1, + maxInstances: 5, + cpuThreshold: 80, +}; + +export const Scaling = () => { + const { + setValue, + formState: { isValid }, + control, + } = useForm({ + resolver: zodResolver(scalingSchema), + mode: "onChange", + defaultValues: DEFAULT_VALUES, + }); + + const currentMin = useWatch({ control, name: "minInstances" }); + const currentMax = useWatch({ control, name: "maxInstances" }); + const currentCpuThreshold = useWatch({ control, name: "cpuThreshold" }); + + const hasChanges = + currentMin !== DEFAULT_VALUES.minInstances || + currentMax !== DEFAULT_VALUES.maxInstances || + currentCpuThreshold !== DEFAULT_VALUES.cpuThreshold; + + return ( + } + title="Scaling" + description="Autoscaling instance range and CPU trigger threshold" + displayValue={ +
+ + {DEFAULT_VALUES.minInstances} – {DEFAULT_VALUES.maxInstances} + + instances + · + {DEFAULT_VALUES.cpuThreshold}% + CPU +
+ } + onSubmit={(e) => e.preventDefault()} + canSave={isValid && hasChanges} + isSaving={false} + > +
+
+ Autoscale range +
+ { + if (min !== undefined) { + setValue("minInstances", min, { shouldValidate: true }); + } + if (max !== undefined) { + setValue("maxInstances", max, { shouldValidate: true }); + } + }} + className="flex-1 max-w-[480px]" + rangeStyle={{ + background: + "linear-gradient(to right, hsla(var(--featureA-4)), hsla(var(--featureA-12)))", + backgroundRepeat: "no-repeat", + }} + /> + + + {currentMin} – {currentMax} + {" "} + instances + +
+ + Minimum and maximum number of instances across all regions. Autoscaler stays within this + range. + +
+
+ CPU threshold +
+ { + if (value !== undefined) { + setValue("cpuThreshold", value, { shouldValidate: true }); + } + }} + className="flex-1 max-w-[480px]" + rangeStyle={{ + background: + "linear-gradient(to right, hsla(var(--warningA-4)), hsla(var(--warningA-12)))", + backgroundRepeat: "no-repeat", + }} + /> + + {currentCpuThreshold}% + +
+ + Scale up when average CPU across instances exceeds this percentage. Changes apply on + next deploy. + +
+
+
+ ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/storage.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/storage.tsx new file mode 100644 index 0000000000..8dee3c6a69 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/storage.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { formatMemory } from "@/lib/utils/deployment-formatters"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Harddrive } from "@unkey/icons"; +import { Slider } from "@unkey/ui"; +import { useForm, useWatch } from "react-hook-form"; +import { z } from "zod"; +import { FormSettingCard } from "../shared/form-setting-card"; +import { SettingDescription } from "../shared/setting-description"; +import { indexToValue, valueToIndex } from "../shared/slider-utils"; + +const STORAGE_OPTIONS = [ + { label: "512 MiB", value: 512 }, + { label: "1 GiB", value: 1024 }, + { label: "2 GiB", value: 2048 }, + { label: "5 GiB", value: 5120 }, + { label: "10 GiB", value: 10240 }, + { label: "20 GiB", value: 20480 }, + { label: "50 GiB", value: 51200 }, +] as const; + +const DEFAULT_STORAGE_MIB = 1024; + +const storageSchema = z.object({ + storage: z.number(), +}); + +type StorageFormValues = z.infer; + +export const Storage = () => { + return ; +}; + +type StorageFormProps = { + defaultStorage: number; +}; + +const StorageForm: React.FC = ({ defaultStorage }) => { + const { + setValue, + formState: { isValid }, + control, + } = useForm({ + resolver: zodResolver(storageSchema), + mode: "onChange", + defaultValues: { storage: defaultStorage }, + }); + + const currentStorage = useWatch({ control, name: "storage" }); + + const hasChanges = currentStorage !== defaultStorage; + const currentIndex = valueToIndex(STORAGE_OPTIONS, currentStorage); + + return ( + } + title="Storage" + description="Ephemeral disk space per instance" + displayValue={(() => { + const [value, unit] = parseStorageDisplay(defaultStorage); + return ( +
+ {value} + {unit} +
+ ); + })()} + onSubmit={(e) => e.preventDefault()} + canSave={isValid && hasChanges} + isSaving={false} + > +
+ Storage per instance +
+ { + if (value !== undefined) { + setValue("storage", indexToValue(STORAGE_OPTIONS, value, 1024), { + shouldValidate: true, + }); + } + }} + className="flex-1 max-w-[480px]" + rangeStyle={{ + background: + "linear-gradient(to right, hsla(var(--successA-4)), hsla(var(--successA-12)))", + backgroundSize: `${currentIndex > 0 ? 100 / (currentIndex / (STORAGE_OPTIONS.length - 1)) : 100}% 100%`, + backgroundRepeat: "no-repeat", + }} + /> + + {formatMemory(currentStorage)} + +
+ + Temporary disk for logs, caches, and scratch data. Changes apply on next deploy. + +
+
+ ); +}; + +function parseStorageDisplay(mib: number): [string, string] { + if (mib >= 1024) { + return [`${(mib / 1024).toFixed(mib % 1024 === 0 ? 0 : 1)}`, "GiB"]; + } + return [`${mib}`, "MiB"]; +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/form-setting-card.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/form-setting-card.tsx new file mode 100644 index 0000000000..ca6b3e47c2 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/form-setting-card.tsx @@ -0,0 +1,72 @@ +import { cn } from "@/lib/utils"; +import { Button, SettingCard, type SettingCardBorder } from "@unkey/ui"; +import type React from "react"; +import { SelectedConfig } from "./selected-config"; + +type EditableSettingCardProps = { + icon: React.ReactNode; + title: string; + description: string; + border?: SettingCardBorder; + + displayValue: React.ReactNode; + + onSubmit: React.FormEventHandler; + children: React.ReactNode; + + canSave: boolean; + isSaving: boolean; + + ref?: React.Ref; + className?: string; +}; + +export const FormSettingCard = ({ + icon, + title, + description, + border, + displayValue, + onSubmit, + children, + canSave, + isSaving, + ref, + className, +}: EditableSettingCardProps) => { + return ( + +
+ {children} +
+
+ +
+ + } + > + +
+ ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/selected-config.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/selected-config.tsx new file mode 100644 index 0000000000..8d67b7b53f --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/selected-config.tsx @@ -0,0 +1,21 @@ +import { cn } from "@/lib/utils"; +import { Badge } from "@unkey/ui"; + +type SelectedConfigProps = { + label: React.ReactNode; + className?: string; +}; + +export const SelectedConfig = ({ label, className = "" }: SelectedConfigProps) => { + return ( + + {label} + + ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/setting-description.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/setting-description.tsx new file mode 100644 index 0000000000..709fa68542 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/setting-description.tsx @@ -0,0 +1,16 @@ +import { CircleInfo } from "@unkey/icons"; + +type SettingDescriptionProps = { + children: React.ReactNode; +}; + +export const SettingDescription: React.FC = ({ children }) => { + return ( +
+ + +
+ ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/settings-group.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/settings-group.tsx new file mode 100644 index 0000000000..f53e18ffbb --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/settings-group.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { ChevronRight } from "@unkey/icons"; +import React, { useState } from "react"; + +type SettingsGroupProps = { + icon: React.ReactNode; + title: string; + children: React.ReactNode; + defaultExpanded?: boolean; +}; + +export const SettingsGroup = ({ + icon, + title, + children, + defaultExpanded = true, +}: SettingsGroupProps) => { + const [expanded, setExpanded] = useState(defaultExpanded); + + return ( +
+
+
+
{icon}
+ {title} +
+ +
+
+
+ {React.Children.map(children, (child, index) => ( +
+ {child} +
+ ))} +
+
+
+ ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/slider-utils.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/slider-utils.ts new file mode 100644 index 0000000000..bcb71342ce --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/slider-utils.ts @@ -0,0 +1,14 @@ +type SliderOption = { readonly label: string; readonly value: number }; + +export function valueToIndex(options: T, value: number): number { + const idx = options.findIndex((o) => o.value === value); + return idx >= 0 ? idx : 0; +} + +export function indexToValue( + options: T, + index: number, + fallback: number, +): number { + return options[index]?.value ?? fallback; +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/page.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/page.tsx index 52b41f9c49..a037f4d57e 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/page.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/page.tsx @@ -1,68 +1,66 @@ "use client"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@unkey/ui"; -import { parseAsString, useQueryState } from "nuqs"; -import { useProjectData } from "../data-provider"; -import { BuildSettings } from "./components/build-settings"; -import { GitHubSettingsClient } from "./components/github-settings-client"; -import { RuntimeApplicationSettings } from "./components/runtime-application-settings"; -import { RuntimeScalingSettings } from "./components/runtime-scaling-settings"; -export const dynamic = "force-dynamic"; +import { CircleHalfDottedClock, Gear } from "@unkey/icons"; +import { SettingCardGroup } from "@unkey/ui"; -export default function SettingsPage() { - const { environments } = useProjectData(); - const [environmentId, setEnvironmentId] = useQueryState( - "environmentId", - parseAsString.withDefault(environments.length > 0 ? environments[0].id : "").withOptions({ - history: "replace", - shallow: true, - }), - ); +import { DockerfileSettings } from "./components/build-settings/dockerfile-settings"; +import { GitHubSettings } from "./components/build-settings/github-settings"; +import { PortSettings } from "./components/build-settings/port-settings"; +import { RootDirectorySettings } from "./components/build-settings/root-directory-settings"; + +import { Cpu } from "./components/runtime-settings/cpu"; +import { Healthcheck } from "./components/runtime-settings/healthcheck"; +import { Instances } from "./components/runtime-settings/instances"; +import { Memory } from "./components/runtime-settings/memory"; +import { Regions } from "./components/runtime-settings/regions"; + +import { Command } from "./components/advanced-settings/command"; +import { CustomDomains } from "./components/advanced-settings/custom-domains"; +import { EnvVars } from "./components/advanced-settings/env-vars"; +import { SettingsGroup } from "./components/shared/settings-group"; + +export default function SettingsPage() { return ( -
-
-
- Project Settings -
-
-
-

Source

- -
-
-
-

Environment

- -
- {environmentId !== null && ( -
-
-

Build

- -
-
-

Runtime

- -
-
-

Scaling

- -
-
- )} +
+
+ Configure deployment + + Review the defaults. Edit anything you'd like to adjust. + +
+
+
+ + + + + +
+ } + title="Runtime settings" + > + + + + + + {/* Temporarily disabled */} + {/* */} + + {/* Temporarily disabled */} + {/* */} + + + } title="Advanced configurations"> + + + + + +
); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/ratelimits/[namespaceId]/settings/components/settings-client.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/ratelimits/[namespaceId]/settings/components/settings-client.tsx index 1be2005dbc..45858aa0a4 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/ratelimits/[namespaceId]/settings/components/settings-client.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/ratelimits/[namespaceId]/settings/components/settings-client.tsx @@ -89,7 +89,7 @@ export const SettingsClient = ({ namespaceId }: Props) => {
} border="top" - className="border-b" + className="border-b border-grayA-4" contentWidth="w-full lg:w-[420px] h-full justify-end items-end" >
diff --git a/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/build/update-docker-context.ts b/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/build/update-docker-context.ts new file mode 100644 index 0000000000..99568137a4 --- /dev/null +++ b/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/build/update-docker-context.ts @@ -0,0 +1,23 @@ +import { and, db, eq } from "@/lib/db"; +import { environmentBuildSettings } from "@unkey/db/src/schema"; +import { z } from "zod"; +import { workspaceProcedure } from "../../../../trpc"; + +export const updateDockerContext = workspaceProcedure + .input( + z.object({ + environmentId: z.string(), + dockerContext: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { + await db + .update(environmentBuildSettings) + .set({ dockerContext: input.dockerContext }) + .where( + and( + eq(environmentBuildSettings.workspaceId, ctx.workspace.id), + eq(environmentBuildSettings.environmentId, input.environmentId), + ), + ); + }); diff --git a/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/build/update-dockerfile.ts b/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/build/update-dockerfile.ts new file mode 100644 index 0000000000..b18f21d3bf --- /dev/null +++ b/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/build/update-dockerfile.ts @@ -0,0 +1,23 @@ +import { and, db, eq } from "@/lib/db"; +import { environmentBuildSettings } from "@unkey/db/src/schema"; +import { z } from "zod"; +import { workspaceProcedure } from "../../../../trpc"; + +export const updateDockerfile = workspaceProcedure + .input( + z.object({ + environmentId: z.string(), + dockerfile: z.string().min(1), + }), + ) + .mutation(async ({ ctx, input }) => { + await db + .update(environmentBuildSettings) + .set({ dockerfile: input.dockerfile }) + .where( + and( + eq(environmentBuildSettings.workspaceId, ctx.workspace.id), + eq(environmentBuildSettings.environmentId, input.environmentId), + ), + ); + }); diff --git a/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/get-available-regions.ts b/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/get-available-regions.ts new file mode 100644 index 0000000000..bc0999979b --- /dev/null +++ b/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/get-available-regions.ts @@ -0,0 +1,9 @@ +import { workspaceProcedure } from "../../../trpc"; + +export const getAvailableRegions = workspaceProcedure.query(() => { + const regionsEnv = process.env.AVAILABLE_REGIONS ?? ""; + return regionsEnv + .split(",") + .map((r) => r.trim()) + .filter(Boolean); +}); diff --git a/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/runtime/update-command.ts b/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/runtime/update-command.ts new file mode 100644 index 0000000000..b98f7c1ac7 --- /dev/null +++ b/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/runtime/update-command.ts @@ -0,0 +1,23 @@ +import { and, db, eq } from "@/lib/db"; +import { environmentRuntimeSettings } from "@unkey/db/src/schema"; +import { z } from "zod"; +import { workspaceProcedure } from "../../../../trpc"; + +export const updateCommand = workspaceProcedure + .input( + z.object({ + environmentId: z.string(), + command: z.array(z.string()), + }), + ) + .mutation(async ({ ctx, input }) => { + await db + .update(environmentRuntimeSettings) + .set({ command: input.command }) + .where( + and( + eq(environmentRuntimeSettings.workspaceId, ctx.workspace.id), + eq(environmentRuntimeSettings.environmentId, input.environmentId), + ), + ); + }); diff --git a/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/runtime/update-cpu.ts b/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/runtime/update-cpu.ts new file mode 100644 index 0000000000..4048027f4e --- /dev/null +++ b/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/runtime/update-cpu.ts @@ -0,0 +1,23 @@ +import { and, db, eq } from "@/lib/db"; +import { environmentRuntimeSettings } from "@unkey/db/src/schema"; +import { z } from "zod"; +import { workspaceProcedure } from "../../../../trpc"; + +export const updateCpu = workspaceProcedure + .input( + z.object({ + environmentId: z.string(), + cpuMillicores: z.number(), + }), + ) + .mutation(async ({ ctx, input }) => { + await db + .update(environmentRuntimeSettings) + .set({ cpuMillicores: input.cpuMillicores }) + .where( + and( + eq(environmentRuntimeSettings.workspaceId, ctx.workspace.id), + eq(environmentRuntimeSettings.environmentId, input.environmentId), + ), + ); + }); diff --git a/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/runtime/update-healthcheck.ts b/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/runtime/update-healthcheck.ts new file mode 100644 index 0000000000..cb2ed46a95 --- /dev/null +++ b/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/runtime/update-healthcheck.ts @@ -0,0 +1,32 @@ +import { and, db, eq } from "@/lib/db"; +import { environmentRuntimeSettings } from "@unkey/db/src/schema"; +import { z } from "zod"; +import { workspaceProcedure } from "../../../../trpc"; + +export const updateHealthcheck = workspaceProcedure + .input( + z.object({ + environmentId: z.string(), + healthcheck: z + .object({ + method: z.enum(["GET", "POST"]), + path: z.string(), + intervalSeconds: z.number().default(10), + timeoutSeconds: z.number().default(5), + failureThreshold: z.number().default(3), + initialDelaySeconds: z.number().default(0), + }) + .nullable(), + }), + ) + .mutation(async ({ ctx, input }) => { + await db + .update(environmentRuntimeSettings) + .set({ healthcheck: input.healthcheck }) + .where( + and( + eq(environmentRuntimeSettings.workspaceId, ctx.workspace.id), + eq(environmentRuntimeSettings.environmentId, input.environmentId), + ), + ); + }); diff --git a/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/runtime/update-instances.ts b/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/runtime/update-instances.ts new file mode 100644 index 0000000000..5f04c22e22 --- /dev/null +++ b/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/runtime/update-instances.ts @@ -0,0 +1,46 @@ +import { and, db, eq } from "@/lib/db"; +import { environmentRuntimeSettings } from "@unkey/db/src/schema"; +import { z } from "zod"; +import { workspaceProcedure } from "../../../../trpc"; + +export const updateInstances = workspaceProcedure + .input( + z.object({ + environmentId: z.string(), + replicasPerRegion: z.number().min(1).max(10), + }), + ) + .mutation(async ({ ctx, input }) => { + const existing = await db.query.environmentRuntimeSettings.findFirst({ + where: and( + eq(environmentRuntimeSettings.workspaceId, ctx.workspace.id), + eq(environmentRuntimeSettings.environmentId, input.environmentId), + ), + }); + + const currentConfig = (existing?.regionConfig as Record) ?? {}; + const currentRegions = Object.keys(currentConfig); + + const regionConfig: Record = {}; + + if (currentRegions.length > 0) { + for (const region of currentRegions) { + regionConfig[region] = input.replicasPerRegion; + } + } else { + const regionsEnv = process.env.AVAILABLE_REGIONS ?? ""; + for (const region of regionsEnv.split(",")) { + regionConfig[region] = input.replicasPerRegion; + } + } + + await db + .update(environmentRuntimeSettings) + .set({ regionConfig }) + .where( + and( + eq(environmentRuntimeSettings.workspaceId, ctx.workspace.id), + eq(environmentRuntimeSettings.environmentId, input.environmentId), + ), + ); + }); diff --git a/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/runtime/update-memory.ts b/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/runtime/update-memory.ts new file mode 100644 index 0000000000..3417a156a9 --- /dev/null +++ b/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/runtime/update-memory.ts @@ -0,0 +1,23 @@ +import { and, db, eq } from "@/lib/db"; +import { environmentRuntimeSettings } from "@unkey/db/src/schema"; +import { z } from "zod"; +import { workspaceProcedure } from "../../../../trpc"; + +export const updateMemory = workspaceProcedure + .input( + z.object({ + environmentId: z.string(), + memoryMib: z.number(), + }), + ) + .mutation(async ({ ctx, input }) => { + await db + .update(environmentRuntimeSettings) + .set({ memoryMib: input.memoryMib }) + .where( + and( + eq(environmentRuntimeSettings.workspaceId, ctx.workspace.id), + eq(environmentRuntimeSettings.environmentId, input.environmentId), + ), + ); + }); diff --git a/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/runtime/update-port.ts b/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/runtime/update-port.ts new file mode 100644 index 0000000000..d7fad41c44 --- /dev/null +++ b/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/runtime/update-port.ts @@ -0,0 +1,23 @@ +import { and, db, eq } from "@/lib/db"; +import { environmentRuntimeSettings } from "@unkey/db/src/schema"; +import { z } from "zod"; +import { workspaceProcedure } from "../../../../trpc"; + +export const updatePort = workspaceProcedure + .input( + z.object({ + environmentId: z.string(), + port: z.number().int().min(2000).max(54000), + }), + ) + .mutation(async ({ ctx, input }) => { + await db + .update(environmentRuntimeSettings) + .set({ port: input.port }) + .where( + and( + eq(environmentRuntimeSettings.workspaceId, ctx.workspace.id), + eq(environmentRuntimeSettings.environmentId, input.environmentId), + ), + ); + }); diff --git a/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/runtime/update-regions.ts b/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/runtime/update-regions.ts new file mode 100644 index 0000000000..acf3e9d9c3 --- /dev/null +++ b/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/runtime/update-regions.ts @@ -0,0 +1,36 @@ +import { and, db, eq } from "@/lib/db"; +import { environmentRuntimeSettings } from "@unkey/db/src/schema"; +import { z } from "zod"; +import { workspaceProcedure } from "../../../../trpc"; + +export const updateRegions = workspaceProcedure + .input( + z.object({ + environmentId: z.string(), + regions: z.array(z.string()).min(1), + }), + ) + .mutation(async ({ ctx, input }) => { + const existing = await db.query.environmentRuntimeSettings.findFirst({ + where: and( + eq(environmentRuntimeSettings.workspaceId, ctx.workspace.id), + eq(environmentRuntimeSettings.environmentId, input.environmentId), + ), + }); + + const currentConfig = (existing?.regionConfig as Record) ?? {}; + const regionConfig: Record = {}; + for (const region of input.regions) { + regionConfig[region] = currentConfig[region] ?? 1; + } + + await db + .update(environmentRuntimeSettings) + .set({ regionConfig }) + .where( + and( + eq(environmentRuntimeSettings.workspaceId, ctx.workspace.id), + eq(environmentRuntimeSettings.environmentId, input.environmentId), + ), + ); + }); diff --git a/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/update-build.ts b/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/update-build.ts deleted file mode 100644 index 904652d19d..0000000000 --- a/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/update-build.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { db } from "@/lib/db"; -import { environmentBuildSettings } from "@unkey/db/src/schema"; -import { z } from "zod"; -import { workspaceProcedure } from "../../../trpc"; - -type BuildSettings = typeof environmentBuildSettings.$inferInsert; - -export const updateEnvironmentBuildSettings = workspaceProcedure - .input( - z.object({ - environmentId: z.string(), - dockerfile: z.string().optional(), - dockerContext: z.string().optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - const dockerfile = input.dockerfile || "Dockerfile"; - const dockerContext = input.dockerContext || "."; - - const values: BuildSettings = { - workspaceId: ctx.workspace.id, - environmentId: input.environmentId, - dockerfile, - dockerContext, - createdAt: Date.now(), - }; - - await db.insert(environmentBuildSettings).values(values).onDuplicateKeyUpdate({ set: values }); - }); diff --git a/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/update-runtime.ts b/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/update-runtime.ts deleted file mode 100644 index 200ebcb92c..0000000000 --- a/web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/update-runtime.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { db } from "@/lib/db"; -import { environmentRuntimeSettings } from "@unkey/db/src/schema"; -import { z } from "zod"; -import { workspaceProcedure } from "../../../trpc"; - -type RuntimeSettings = typeof environmentRuntimeSettings.$inferInsert; - -export const updateEnvironmentRuntimeSettings = workspaceProcedure - .input( - z.object({ - environmentId: z.string(), - port: z.number().min(2000).max(54000).optional(), - command: z.array(z.string()).optional(), - healthcheck: z - .object({ - method: z.enum(["GET", "POST"]), - path: z.string(), - intervalSeconds: z.number().default(10), - timeoutSeconds: z.number().default(5), - failureThreshold: z.number().default(3), - initialDelaySeconds: z.number().default(0), - }) - .nullable() - .optional(), - cpuMillicores: z.number().optional(), - memoryMib: z.number().optional(), - replicasPerRegion: z.number().min(1).max(10).optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - const regionConfig: Record = {}; - if (input.replicasPerRegion !== undefined) { - const regionsEnv = process.env.AVAILABLE_REGIONS ?? ""; - for (const region of regionsEnv.split(",")) { - regionConfig[region] = input.replicasPerRegion; - } - } - - const values: RuntimeSettings = { - workspaceId: ctx.workspace.id, - environmentId: input.environmentId, - port: input.port ?? 8080, - command: input.command ?? [], - healthcheck: input.healthcheck ?? undefined, - cpuMillicores: input.cpuMillicores ?? 256, - memoryMib: input.memoryMib ?? 256, - regionConfig: regionConfig ?? {}, - createdAt: Date.now(), - }; - - await db - .insert(environmentRuntimeSettings) - .values(values) - .onDuplicateKeyUpdate({ set: values }); - }); diff --git a/web/apps/dashboard/lib/trpc/routers/index.ts b/web/apps/dashboard/lib/trpc/routers/index.ts index 38996b1329..b15b9675d0 100644 --- a/web/apps/dashboard/lib/trpc/routers/index.ts +++ b/web/apps/dashboard/lib/trpc/routers/index.ts @@ -55,9 +55,17 @@ import { decryptEnvVar } from "./deploy/env-vars/decrypt"; import { deleteEnvVar } from "./deploy/env-vars/delete"; import { listEnvVars } from "./deploy/env-vars/list"; import { updateEnvVar } from "./deploy/env-vars/update"; +import { updateDockerContext } from "./deploy/environment-settings/build/update-docker-context"; +import { updateDockerfile } from "./deploy/environment-settings/build/update-dockerfile"; import { getEnvironmentSettings } from "./deploy/environment-settings/get"; -import { updateEnvironmentBuildSettings } from "./deploy/environment-settings/update-build"; -import { updateEnvironmentRuntimeSettings } from "./deploy/environment-settings/update-runtime"; +import { getAvailableRegions } from "./deploy/environment-settings/get-available-regions"; +import { updateCommand } from "./deploy/environment-settings/runtime/update-command"; +import { updateCpu } from "./deploy/environment-settings/runtime/update-cpu"; +import { updateHealthcheck } from "./deploy/environment-settings/runtime/update-healthcheck"; +import { updateInstances } from "./deploy/environment-settings/runtime/update-instances"; +import { updateMemory } from "./deploy/environment-settings/runtime/update-memory"; +import { updatePort } from "./deploy/environment-settings/runtime/update-port"; +import { updateRegions } from "./deploy/environment-settings/runtime/update-regions"; import { getDeploymentLatency } from "./deploy/metrics/get-deployment-latency"; import { getDeploymentLatencyTimeseries } from "./deploy/metrics/get-deployment-latency-timeseries"; import { getDeploymentRps } from "./deploy/metrics/get-deployment-rps"; @@ -395,8 +403,20 @@ export const router = t.router({ }), environmentSettings: t.router({ get: getEnvironmentSettings, - updateBuild: updateEnvironmentBuildSettings, - updateRuntime: updateEnvironmentRuntimeSettings, + getAvailableRegions, + runtime: t.router({ + updateCpu, + updateMemory, + updatePort, + updateCommand, + updateHealthcheck, + updateRegions, + updateInstances, + }), + build: t.router({ + updateDockerfile, + updateDockerContext, + }), }), environment: t.router({ list: listEnvironments, diff --git a/web/internal/icons/src/icons/connections3.tsx b/web/internal/icons/src/icons/connections3.tsx new file mode 100644 index 0000000000..938b3d887c --- /dev/null +++ b/web/internal/icons/src/icons/connections3.tsx @@ -0,0 +1,78 @@ +/** + * 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 IconProps, sizeMap } from "../props"; + +export function Connections3({ iconSize = "xl-thin", ...props }: IconProps) { + const { iconSize: pixelSize, strokeWidth } = sizeMap[iconSize]; + + return ( + + + + + + + + + ); +} diff --git a/web/internal/icons/src/icons/file-settings.tsx b/web/internal/icons/src/icons/file-settings.tsx new file mode 100644 index 0000000000..91761a9811 --- /dev/null +++ b/web/internal/icons/src/icons/file-settings.tsx @@ -0,0 +1,133 @@ +/** + * 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 IconProps, sizeMap } from "../props"; + +export function FileSettings({ iconSize = "xl-thin", ...props }: IconProps) { + const { iconSize: pixelSize, strokeWidth } = sizeMap[iconSize]; + return ( + + + + + + + + + + + + + + + + + + ); +} diff --git a/web/internal/icons/src/icons/folder-link.tsx b/web/internal/icons/src/icons/folder-link.tsx new file mode 100644 index 0000000000..49b0a9c0f9 --- /dev/null +++ b/web/internal/icons/src/icons/folder-link.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 IconProps, sizeMap } from "../props"; + +export function FolderLink({ iconSize = "xl-thin", ...props }: IconProps) { + const { iconSize: pixelSize, strokeWidth } = sizeMap[iconSize]; + return ( + + + + + + + + + + ); +} diff --git a/web/internal/icons/src/icons/heart-pulse.tsx b/web/internal/icons/src/icons/heart-pulse.tsx new file mode 100644 index 0000000000..21b023311b --- /dev/null +++ b/web/internal/icons/src/icons/heart-pulse.tsx @@ -0,0 +1,54 @@ +/** + * 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 IconProps, sizeMap } from "../props"; + +export function HeartPulse({ iconSize = "xl-thin", ...props }: IconProps) { + const { iconSize: pixelSize, strokeWidth } = sizeMap[iconSize]; + + return ( + + + + + + + + ); +} diff --git a/web/internal/icons/src/icons/nodes-2.tsx b/web/internal/icons/src/icons/nodes-2.tsx new file mode 100644 index 0000000000..773cf79dc3 --- /dev/null +++ b/web/internal/icons/src/icons/nodes-2.tsx @@ -0,0 +1,134 @@ +/** + * 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 IconProps, sizeMap } from "../props"; + +export function Nodes2({ iconSize = "xl-thin", ...props }: IconProps) { + const { iconSize: pixelSize, strokeWidth } = sizeMap[iconSize]; + + return ( + + + + + + + + + + + + + + + + + + ); +} diff --git a/web/internal/icons/src/icons/scan-code.tsx b/web/internal/icons/src/icons/scan-code.tsx new file mode 100644 index 0000000000..eceb4fc86c --- /dev/null +++ b/web/internal/icons/src/icons/scan-code.tsx @@ -0,0 +1,106 @@ +/** + * 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 IconProps, sizeMap } from "../props"; + +export function ScanCode({ iconSize = "xl-thin", filled, ...props }: IconProps) { + const { iconSize: pixelSize, strokeWidth } = sizeMap[iconSize]; + + return ( + + + + + + + + + + + + + ); +} diff --git a/web/internal/icons/src/icons/square-terminal.tsx b/web/internal/icons/src/icons/square-terminal.tsx new file mode 100644 index 0000000000..bfb1d7e63e --- /dev/null +++ b/web/internal/icons/src/icons/square-terminal.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 IconProps, sizeMap } from "../props"; + +export function SquareTerminal({ iconSize = "xl-thin", ...props }: IconProps) { + const { iconSize: pixelSize, strokeWidth } = sizeMap[iconSize]; + + return ( + + + + + + + + ); +} diff --git a/web/internal/icons/src/index.ts b/web/internal/icons/src/index.ts index dfb9bed114..225b09e452 100644 --- a/web/internal/icons/src/index.ts +++ b/web/internal/icons/src/index.ts @@ -61,6 +61,7 @@ export * from "./icons/code"; export * from "./icons/code-branch"; export * from "./icons/code-commit"; export * from "./icons/coins"; +export * from "./icons/connections3"; export * from "./icons/connections"; export * from "./icons/conversion"; export * from "./icons/cube"; @@ -71,10 +72,12 @@ export * from "./icons/earth"; export * from "./icons/envelope"; export * from "./icons/eye-slash"; export * from "./icons/eye"; +export * from "./icons/file-settings"; export * from "./icons/external-link"; export * from "./icons/fingerprint"; export * from "./icons/focus"; export * from "./icons/folder-cloud"; +export * from "./icons/folder-link"; export * from "./icons/gauge"; export * from "./icons/gear"; export * from "./icons/github"; @@ -82,6 +85,7 @@ 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-pulse"; export * from "./icons/heart"; export * from "./icons/input-password-edit"; export * from "./icons/input-password-settings"; @@ -101,6 +105,7 @@ export * from "./icons/math-function"; export * from "./icons/message-writing"; export * from "./icons/minus"; export * from "./icons/moon-stars"; +export * from "./icons/nodes-2"; export * from "./icons/nodes"; export * from "./icons/number-input"; export * from "./icons/nut"; @@ -111,6 +116,7 @@ export * from "./icons/plus"; export * from "./icons/progress-bar"; export * from "./icons/pulse"; export * from "./icons/refresh-3"; +export * from "./icons/scan-code"; export * from "./icons/share-up-right"; export * from "./icons/shield"; export * from "./icons/shield-alert"; @@ -120,6 +126,7 @@ export * from "./icons/sidebar-left-hide"; export * from "./icons/sidebar-left-show"; export * from "./icons/sliders"; export * from "./icons/sparkle-3"; +export * from "./icons/square-terminal"; export * from "./icons/stack-perspective-2"; export * from "./icons/storage"; export * from "./icons/sun"; diff --git a/web/internal/ui/package.json b/web/internal/ui/package.json index 8743de9ef0..de95f3d5ee 100644 --- a/web/internal/ui/package.json +++ b/web/internal/ui/package.json @@ -25,6 +25,7 @@ "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.8", + "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "1.2.4", "@radix-ui/react-tabs": "1.1.0", "@radix-ui/react-tooltip": "1.2.8", diff --git a/web/internal/ui/src/components/form/form-checkbox.tsx b/web/internal/ui/src/components/form/form-checkbox.tsx index e674b0fed3..00066e09ce 100644 --- a/web/internal/ui/src/components/form/form-checkbox.tsx +++ b/web/internal/ui/src/components/form/form-checkbox.tsx @@ -55,15 +55,17 @@ const FormCheckbox = React.forwardRef( aria-required={required} {...props} /> -
- -
+ {label && ( +
+ +
+ )}
+
+ {children} +
+ + ); +} +SettingCardGroup.displayName = "SettingCardGroup"; + function SettingCard({ title, description, @@ -18,40 +41,149 @@ function SettingCard({ className, border = "default", contentWidth = "w-[420px]", + icon, + expandable, + defaultExpanded = false, + chevronState, }: SettingCardProps) { - const borderRadiusClass = { - "rounded-t-xl": border === "top", - "rounded-b-xl": border === "bottom", - "rounded-xl": border === "both", - "": border === "none" || border === "default", + const [isExpanded, setIsExpanded] = React.useState(defaultExpanded); + const contentRef = React.useRef(null); + const innerRef = React.useRef(null); + const [contentHeight, setContentHeight] = React.useState(0); + const inGroup = React.useContext(SettingCardGroupContext); + + React.useEffect(() => { + const inner = innerRef.current; + if (!inner) { + return; + } + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setContentHeight(entry.borderBoxSize[0].blockSize); + } + }); + observer.observe(inner); + return () => observer.disconnect(); + }, []); + + // Determine effective chevron state + const effectiveChevronState: ChevronState = + chevronState ?? (expandable ? "interactive" : "hidden"); + + const shouldShowChevron = effectiveChevronState !== "hidden"; + const isInteractive = effectiveChevronState === "interactive" && expandable; + + const getBorderRadiusClass = () => { + if (inGroup) { + return ""; + } + if (border === "none" || border === "default") { + return ""; + } + if (border === "top") { + return "rounded-t-xl"; + } + if (border === "bottom") { + return !expandable || !isExpanded ? "rounded-b-xl" : ""; + } + if (border === "both") { + const bottom = !expandable || !isExpanded ? "rounded-b-xl" : ""; + return cn("rounded-t-xl", bottom); + } + return ""; }; - const borderClass = { - "border border-grayA-4": border !== "none", - "border-t-0": border === "bottom", - "border-b-0": border === "top", + const borderClass = inGroup + ? {} + : { + "border border-grayA-4": border !== "none", + "border-t-0": border === "bottom", + "border-b-0": border === "top", + }; + + const expandedBottomRadius = + !inGroup && expandable && isExpanded && (border === "bottom" || border === "both") + ? "rounded-b-xl" + : ""; + + const handleToggle = () => { + if (isInteractive) { + setIsExpanded(!isExpanded); + } }; return ( -
-
-
{title}
-
- {description} +
+
{ + if (!isInteractive) { + return; + } + if (e.key === "Enter") { + e.preventDefault(); + handleToggle(); + } + }} + onClick={isInteractive ? handleToggle : undefined} + > +
+ {icon && ( +
+ {icon} +
+ )} +
+
+ {title} +
+
+ {description} +
+
+
+
+ {children} + {shouldShowChevron && ( + + )}
-
{children}
+ {expandable && ( +
+
+ {expandable} +
+
+ )}
); } SettingCard.displayName = "SettingCard"; -export { SettingCard }; +export { SettingCard, SettingCardGroup }; diff --git a/web/internal/ui/src/components/slider.tsx b/web/internal/ui/src/components/slider.tsx new file mode 100644 index 0000000000..22db08633c --- /dev/null +++ b/web/internal/ui/src/components/slider.tsx @@ -0,0 +1,40 @@ +import * as SliderPrimitive from "@radix-ui/react-slider"; +import * as React from "react"; +import { cn } from "../lib/utils"; + +type SliderProps = React.ComponentPropsWithoutRef & { + rangeClassName?: string; + rangeStyle?: React.CSSProperties; +}; + +const Slider = React.forwardRef, SliderProps>( + ({ className, rangeClassName, rangeStyle, value, defaultValue, ...props }, ref) => { + const thumbCount = (value ?? defaultValue ?? [0]).length; + return ( + + + + + {Array.from({ length: thumbCount }).map((_, i) => ( + + key={i} + className="block h-4 w-4 rounded-full border border-grayA-6 bg-gray-2 shadow transition-colors duration-300 hover:border-grayA-8 focus:ring focus:ring-gray-5 focus-visible:outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50" + /> + ))} + + ); + }, +); +Slider.displayName = SliderPrimitive.Root.displayName; + +export { Slider }; diff --git a/web/internal/ui/src/index.ts b/web/internal/ui/src/index.ts index 0a4f386efd..b897bc01f6 100644 --- a/web/internal/ui/src/index.ts +++ b/web/internal/ui/src/index.ts @@ -33,4 +33,5 @@ export * from "./components/tabs"; export * from "./components/separator"; export * from "./components/toaster"; export * from "./components/visually-hidden"; +export * from "./components/slider"; export * from "./hooks/use-mobile"; diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 3128958d32..f912be2ee9 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -480,7 +480,7 @@ importers: devDependencies: checkly: specifier: latest - version: 6.9.8(@types/node@25.0.10)(typescript@5.5.3) + version: 4.19.1(@types/node@25.0.10)(typescript@5.5.3) ts-node: specifier: 10.9.1 version: 10.9.1(@types/node@25.0.10)(typescript@5.5.3) @@ -794,6 +794,9 @@ importers: '@radix-ui/react-separator': specifier: 1.1.8 version: 1.1.8(@types/react-dom@19.2.3)(@types/react@19.2.4)(react-dom@19.2.3)(react@19.2.3) + '@radix-ui/react-slider': + specifier: ^1.3.6 + version: 1.3.6(@types/react-dom@19.2.3)(@types/react@19.2.4)(react-dom@19.2.3)(react@19.2.3) '@radix-ui/react-slot': specifier: 1.2.4 version: 1.2.4(@types/react@19.2.4)(react@19.2.3) @@ -5111,6 +5114,91 @@ packages: engines: {node: '>=12.4.0'} dev: true + /@oclif/color@1.0.13: + resolution: {integrity: sha512-/2WZxKCNjeHlQogCs1VBtJWlPXjwWke/9gMrwsVsrUt00g2V6LUBvwgwrxhrXepjOmq4IZ5QeNbpDMEOUlx/JA==} + engines: {node: '>=12.0.0'} + dependencies: + ansi-styles: 4.3.0 + chalk: 4.1.2 + strip-ansi: 6.0.1 + supports-color: 8.1.1 + tslib: 2.8.1 + dev: true + + /@oclif/core@1.26.2: + resolution: {integrity: sha512-6jYuZgXvHfOIc9GIaS4T3CIKGTjPmfAxuMcbCbMRKJJl4aq/4xeRlEz0E8/hz8HxvxZBGvN2GwAUHlrGWQVrVw==} + engines: {node: '>=14.0.0'} + dependencies: + '@oclif/linewrap': 1.0.0 + '@oclif/screen': 3.0.8 + ansi-escapes: 4.3.2 + ansi-styles: 4.3.0 + cardinal: 2.1.1 + chalk: 4.1.2 + clean-stack: 3.0.1 + cli-progress: 3.12.0 + debug: 4.4.3(supports-color@8.1.1) + ejs: 3.1.10 + fs-extra: 9.1.0 + get-package-type: 0.1.0 + globby: 11.1.0 + hyperlinker: 1.0.0 + indent-string: 4.0.0 + is-wsl: 2.2.0 + js-yaml: 3.14.2 + natural-orderby: 2.0.3 + object-treeify: 1.1.33 + password-prompt: 1.1.3 + semver: 7.7.4 + string-width: 4.2.3 + strip-ansi: 6.0.1 + supports-color: 8.1.1 + supports-hyperlinks: 2.3.0 + tslib: 2.8.1 + widest-line: 3.1.0 + wrap-ansi: 7.0.0 + dev: true + + /@oclif/core@2.8.11(@types/node@25.0.10)(typescript@5.5.3): + resolution: {integrity: sha512-9wYW6KRSWfB/D+tqeyl/jxmEz/xPXkFJGVWfKaptqHz6FPWNJREjAM945MuJL2Y8NRhMe+ScRlZ3WpdToX5aVQ==} + engines: {node: '>=14.0.0'} + dependencies: + '@types/cli-progress': 3.11.6 + ansi-escapes: 4.3.2 + ansi-styles: 4.3.0 + cardinal: 2.1.1 + chalk: 4.1.2 + clean-stack: 3.0.1 + cli-progress: 3.12.0 + debug: 4.4.3(supports-color@8.1.1) + ejs: 3.1.10 + fs-extra: 9.1.0 + get-package-type: 0.1.0 + globby: 11.1.0 + hyperlinker: 1.0.0 + indent-string: 4.0.0 + is-wsl: 2.2.0 + js-yaml: 3.14.2 + natural-orderby: 2.0.3 + object-treeify: 1.1.33 + password-prompt: 1.1.3 + semver: 7.7.4 + string-width: 4.2.3 + strip-ansi: 6.0.1 + supports-color: 8.1.1 + supports-hyperlinks: 2.3.0 + ts-node: 10.9.1(@types/node@25.0.10)(typescript@5.5.3) + tslib: 2.8.1 + widest-line: 3.1.0 + wordwrap: 1.0.0 + wrap-ansi: 7.0.0 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + - '@types/node' + - typescript + dev: true + /@oclif/core@4.8.0: resolution: {integrity: sha512-jteNUQKgJHLHFbbz806aGZqf+RJJ7t4gwF4MYa8fCwCxQ8/klJNWc0MvaJiBebk7Mc+J39mdlsB4XraaCKznFw==} engines: {node: '>=18.0.0'} @@ -5135,27 +5223,34 @@ packages: wrap-ansi: 7.0.0 dev: true - /@oclif/plugin-help@6.2.37: - resolution: {integrity: sha512-5N/X/FzlJaYfpaHwDC0YHzOzKDWa41s9t+4FpCDu4f9OMReds4JeNBaaWk9rlIzdKjh2M6AC5Q18ORfECRkHGA==} - engines: {node: '>=18.0.0'} + /@oclif/linewrap@1.0.0: + resolution: {integrity: sha512-Ups2dShK52xXa8w6iBWLgcjPJWjais6KPJQq3gQ/88AY6BXoTX+MIGFPrWQO1KLMiQfoTpcLnUwloN4brrVUHw==} + dev: true + + /@oclif/plugin-help@5.1.20: + resolution: {integrity: sha512-N8xRxE/isFcdBDI8cobixEZA5toxIK5jbxpwALNTr4s8KNAtBA3ORQrSiY0fWGkcv0sCGMwZw7rJ0Izh18JPsw==} + engines: {node: '>=12.0.0'} dependencies: - '@oclif/core': 4.8.0 + '@oclif/core': 1.26.2 dev: true - /@oclif/plugin-not-found@3.2.74(@types/node@25.0.10): - resolution: {integrity: sha512-6RD/EuIUGxAYR45nMQg+nw+PqwCXUxkR6Eyn+1fvbVjtb9d+60OPwB77LCRUI4zKNI+n0LOFaMniEdSpb+A7kQ==} - engines: {node: '>=18.0.0'} + /@oclif/plugin-not-found@2.3.23(@types/node@25.0.10)(typescript@5.5.3): + resolution: {integrity: sha512-UZM8aolxXvqwH8WcmJxRNASDWgMoSQm/pgCdkc1AGCRevYc8+LBSO+U6nLWq+Dx8H/dn9RyIv5oiUIOGkKDlZA==} + engines: {node: '>=12.0.0'} dependencies: - '@inquirer/prompts': 7.10.1(@types/node@25.0.10) - '@oclif/core': 4.8.0 - ansis: 3.17.0 + '@oclif/color': 1.0.13 + '@oclif/core': 2.8.11(@types/node@25.0.10)(typescript@5.5.3) fast-levenshtein: 3.0.0 + lodash: 4.17.23 transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' - '@types/node' + - typescript dev: true - /@oclif/plugin-plugins@5.4.56: - resolution: {integrity: sha512-mZjRudlmVSr6Stz0CVFuaIZOjwZ5DqjWepQCR/yK9nbs8YunGautpuxBx/CcqaEH29xiQfsuNOIUWa1w/+3VSA==} + /@oclif/plugin-plugins@5.4.4: + resolution: {integrity: sha512-p30fo3JPtbOqTJOX9A/8qKV/14XWt8xFgG/goVfIkuKBAO+cdY78ag8pYatlpzsYzJhO27X1MFn0WkkPWo36Ww==} engines: {node: '>=18.0.0'} dependencies: '@oclif/core': 4.8.0 @@ -5173,18 +5268,29 @@ packages: - supports-color dev: true - /@oclif/plugin-warn-if-update-available@3.1.55: - resolution: {integrity: sha512-VIEBoaoMOCjl3y+w/kdfZMODi0mVMnDuM0vkBf3nqeidhRXVXq87hBqYDdRwN1XoD+eDfE8tBbOP7qtSOONztQ==} - engines: {node: '>=18.0.0'} + /@oclif/plugin-warn-if-update-available@2.0.24(@types/node@25.0.10)(typescript@5.5.3): + resolution: {integrity: sha512-Rq8/EZ8wQawvPWS6W59Zhf/zSz/umLc3q75I1ybi7pul6YMNwf/E1eDVHytSUEQ6yQV+p3cCs034IItz4CVdjw==} + engines: {node: '>=12.0.0'} dependencies: - '@oclif/core': 4.8.0 - ansis: 3.17.0 + '@oclif/core': 2.8.11(@types/node@25.0.10)(typescript@5.5.3) + chalk: 4.1.2 debug: 4.4.3(supports-color@8.1.1) + fs-extra: 9.1.0 http-call: 5.3.0 lodash: 4.17.23 - registry-auth-token: 5.1.1 + semver: 7.7.4 transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + - '@types/node' - supports-color + - typescript + dev: true + + /@oclif/screen@3.0.8: + resolution: {integrity: sha512-yx6KAqlt3TAHBduS2fMQtJDL2ufIHnDRArrJEOoTTuizxqmjLT+psGYOHpmMl3gvQpFJ11Hs76guUUktzAF9Bg==} + engines: {node: '>=12.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. dev: true /@octokit/auth-token@2.5.0: @@ -6228,27 +6334,6 @@ packages: engines: {node: '>=16'} dev: false - /@pnpm/config.env-replace@1.1.0: - resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} - engines: {node: '>=12.22.0'} - dev: true - - /@pnpm/network.ca-file@1.0.2: - resolution: {integrity: sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==} - engines: {node: '>=12.22.0'} - dependencies: - graceful-fs: 4.2.10 - dev: true - - /@pnpm/npm-conf@3.0.2: - resolution: {integrity: sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==} - engines: {node: '>=12'} - dependencies: - '@pnpm/config.env-replace': 1.1.0 - '@pnpm/network.ca-file': 1.0.2 - config-chain: 1.1.13 - dev: true - /@polka/url@1.0.0-next.29: resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} dev: true @@ -8728,6 +8813,36 @@ packages: react-dom: 19.2.3(react@19.2.3) dev: false + /@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3)(@types/react@19.2.4)(react-dom@19.2.3)(react@19.2.3): + resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.4)(react-dom@19.2.3)(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.4)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.4)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.4)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.4)(react-dom@19.2.3)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.4)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.4)(react@19.2.3) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.4)(react@19.2.3) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.4)(react@19.2.3) + '@types/react': 19.2.4 + '@types/react-dom': 19.2.3(@types/react@19.2.4) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + dev: false + /@radix-ui/react-slot@1.0.2(@types/react@19.2.4)(react@19.2.4): resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==} peerDependencies: @@ -11221,6 +11336,12 @@ packages: assertion-error: 2.0.1 dev: true + /@types/cli-progress@3.11.6: + resolution: {integrity: sha512-cE3+jb9WRlu+uOSAugewNpITJDt1VF8dHOopPO4IABFc3SXYL5WE/+PTz/FCdZRRfIujiWW3n3aMbv1eIGVRWA==} + dependencies: + '@types/node': 25.0.10 + dev: true + /@types/connect@3.4.38: resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} dependencies: @@ -11802,20 +11923,6 @@ packages: - supports-color dev: true - /@typescript-eslint/project-service@8.53.1(typescript@5.5.3): - resolution: {integrity: sha512-WYC4FB5Ra0xidsmlPb+1SsnaSKPmS3gsjIARwbEkHkoWloQmuzcfypljaJcR78uyLA1h8sHdWWPHSLDI+MtNog==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - dependencies: - '@typescript-eslint/tsconfig-utils': 8.53.1(typescript@5.5.3) - '@typescript-eslint/types': 8.53.1 - debug: 4.4.3(supports-color@8.1.1) - typescript: 5.5.3 - transitivePeerDependencies: - - supports-color - dev: true - /@typescript-eslint/project-service@8.53.1(typescript@5.7.3): resolution: {integrity: sha512-WYC4FB5Ra0xidsmlPb+1SsnaSKPmS3gsjIARwbEkHkoWloQmuzcfypljaJcR78uyLA1h8sHdWWPHSLDI+MtNog==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -11838,15 +11945,6 @@ packages: '@typescript-eslint/visitor-keys': 8.53.1 dev: true - /@typescript-eslint/tsconfig-utils@8.53.1(typescript@5.5.3): - resolution: {integrity: sha512-qfvLXS6F6b1y43pnf0pPbXJ+YoXIC7HKg0UGZ27uMIemKMKA6XH2DTxsEDdpdN29D+vHV07x/pnlPNVLhdhWiA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - dependencies: - typescript: 5.5.3 - dev: true - /@typescript-eslint/tsconfig-utils@8.53.1(typescript@5.7.3): resolution: {integrity: sha512-qfvLXS6F6b1y43pnf0pPbXJ+YoXIC7HKg0UGZ27uMIemKMKA6XH2DTxsEDdpdN29D+vHV07x/pnlPNVLhdhWiA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -11874,26 +11972,33 @@ packages: - supports-color dev: true + /@typescript-eslint/types@6.19.0: + resolution: {integrity: sha512-lFviGV/vYhOy3m8BJ/nAKoAyNhInTdXpftonhWle66XHAtT1ouBlkjL496b5H5hb8dWXHwtypTqgtb/DEa+j5A==} + engines: {node: ^16.0.0 || >=18.0.0} + dev: true + /@typescript-eslint/types@8.53.1: resolution: {integrity: sha512-jr/swrr2aRmUAUjW5/zQHbMaui//vQlsZcJKijZf3M26bnmLj8LyZUpj8/Rd6uzaek06OWsqdofN/Thenm5O8A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} dev: true - /@typescript-eslint/typescript-estree@8.53.1(typescript@5.5.3): - resolution: {integrity: sha512-RGlVipGhQAG4GxV1s34O91cxQ/vWiHJTDHbXRr0li2q/BGg3RR/7NM8QDWgkEgrwQYCvmJV9ichIwyoKCQ+DTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + /@typescript-eslint/typescript-estree@6.19.0(typescript@5.5.3): + resolution: {integrity: sha512-o/zefXIbbLBZ8YJ51NlkSAt2BamrK6XOmuxSR3hynMIzzyMY33KuJ9vuMdFSXW+H0tVvdF9qBPTHA91HDb4BIQ==} + engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true dependencies: - '@typescript-eslint/project-service': 8.53.1(typescript@5.5.3) - '@typescript-eslint/tsconfig-utils': 8.53.1(typescript@5.5.3) - '@typescript-eslint/types': 8.53.1 - '@typescript-eslint/visitor-keys': 8.53.1 + '@typescript-eslint/types': 6.19.0 + '@typescript-eslint/visitor-keys': 6.19.0 debug: 4.4.3(supports-color@8.1.1) - minimatch: 9.0.5 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.3 semver: 7.7.4 - tinyglobby: 0.2.15 - ts-api-utils: 2.4.0(typescript@5.5.3) + ts-api-utils: 1.4.3(typescript@5.5.3) typescript: 5.5.3 transitivePeerDependencies: - supports-color @@ -11936,6 +12041,14 @@ packages: - supports-color dev: true + /@typescript-eslint/visitor-keys@6.19.0: + resolution: {integrity: sha512-hZaUCORLgubBvtGpp1JEFEazcuEdfxta9j4iUwdSAr7mEsYYAp3EAUyCZk3VEEqGj6W+AV4uWyrDGtrlawAsgQ==} + engines: {node: ^16.0.0 || >=18.0.0} + dependencies: + '@typescript-eslint/types': 6.19.0 + eslint-visitor-keys: 3.4.3 + dev: true + /@typescript-eslint/visitor-keys@8.53.1: resolution: {integrity: sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -12515,6 +12628,11 @@ packages: dependencies: acorn: 8.15.0 + /acorn-walk@8.2.0: + resolution: {integrity: sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==} + engines: {node: '>=0.4.0'} + dev: true + /acorn-walk@8.3.4: resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} engines: {node: '>=0.4.0'} @@ -12533,6 +12651,12 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + /acorn@8.8.1: + resolution: {integrity: sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + /address@1.2.2: resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==} engines: {node: '>= 10.0.0'} @@ -12758,6 +12882,10 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + /ansicolors@0.3.2: + resolution: {integrity: sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==} + dev: true + /ansis@3.17.0: resolution: {integrity: sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==} engines: {node: '>=14'} @@ -12890,6 +13018,11 @@ packages: resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} dev: true + /array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + dev: true + /array.prototype.findlast@1.2.5: resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} engines: {node: '>= 0.4'} @@ -12973,6 +13106,16 @@ packages: tslib: 2.8.1 dev: false + /assert@2.1.0: + resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} + dependencies: + call-bind: 1.0.8 + is-nan: 1.3.2 + object-is: 1.1.6 + object.assign: 4.1.7 + util: 0.12.5 + dev: true + /assertion-error@1.1.0: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} dev: true @@ -13112,6 +13255,16 @@ packages: transitivePeerDependencies: - debug + /axios@1.7.4: + resolution: {integrity: sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==} + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + dev: true + /axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -13300,15 +13453,6 @@ packages: dependencies: fill-range: 7.1.1 - /broker-factory@3.1.13: - resolution: {integrity: sha512-H2VALe31mEtO/SRcNp4cUU5BAm1biwhc/JaF77AigUuni/1YT0FLCJfbUxwIEs9y6Kssjk2fmXgf+Y9ALvmKlw==} - dependencies: - '@babel/runtime': 7.28.6 - fast-unique-numbers: 9.0.26 - tslib: 2.8.1 - worker-factory: 7.0.48 - dev: true - /browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -13450,6 +13594,14 @@ packages: /caniuse-lite@1.0.30001766: resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} + /cardinal@2.1.1: + resolution: {integrity: sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==} + hasBin: true + dependencies: + ansicolors: 0.3.2 + redeyed: 2.1.1 + dev: true + /ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -13558,55 +13710,46 @@ packages: engines: {node: '>= 16'} dev: true - /checkly@6.9.8(@types/node@25.0.10)(typescript@5.5.3): - resolution: {integrity: sha512-7CzBfjp7kVx9Rh+K6a8DLUSsIT84yV8uicZYRjxbGsETdpnawYOopt5RkfxCV73eClECAJoGIHlRrMt9dDSb5A==} - engines: {node: ^18.19.0 || >=20.5.0} + /checkly@4.19.1(@types/node@25.0.10)(typescript@5.5.3): + resolution: {integrity: sha512-KtUzvKWvY4Pa1O2is7s4UK9w3X4G8jVsYntdXLDzwfajsg22bq4qa+n3w2uZehGmbIrUmL638alG76XrRQ5PDQ==} + engines: {node: '>=16.0.0'} hasBin: true - peerDependencies: - jiti: '>=2' - peerDependenciesMeta: - jiti: - optional: true dependencies: - '@oclif/core': 4.8.0 - '@oclif/plugin-help': 6.2.37 - '@oclif/plugin-not-found': 3.2.74(@types/node@25.0.10) - '@oclif/plugin-plugins': 5.4.56 - '@oclif/plugin-warn-if-update-available': 3.1.55 - '@typescript-eslint/typescript-estree': 8.53.1(typescript@5.5.3) - acorn: 8.15.0 - acorn-walk: 8.3.4 - archiver: 7.0.1 - axios: 1.13.2 + '@oclif/core': 2.8.11(@types/node@25.0.10)(typescript@5.5.3) + '@oclif/plugin-help': 5.1.20 + '@oclif/plugin-not-found': 2.3.23(@types/node@25.0.10)(typescript@5.5.3) + '@oclif/plugin-plugins': 5.4.4 + '@oclif/plugin-warn-if-update-available': 2.0.24(@types/node@25.0.10)(typescript@5.5.3) + '@typescript-eslint/typescript-estree': 6.19.0(typescript@5.5.3) + acorn: 8.8.1 + acorn-walk: 8.2.0 + axios: 1.7.4 chalk: 4.1.2 - ci-info: 4.4.0 + ci-info: 3.8.0 conf: 10.2.0 - dotenv: 16.6.1 - execa: 9.6.1 + dotenv: 16.3.1 git-repo-info: 2.1.1 - glob: 10.5.0 + glob: 10.3.1 indent-string: 4.0.0 json-stream-stringify: 3.1.6 json5: 2.2.3 jwt-decode: 3.1.2 log-symbols: 4.1.0 - luxon: 3.7.2 - minimatch: 9.0.5 - mqtt: 5.15.0 - open: 8.4.2 + luxon: 3.3.0 + mqtt: 5.10.1 + open: 8.4.0 p-queue: 6.6.2 prompts: 2.4.2 proxy-from-env: 1.1.0 - recast: 0.23.11 - semver: 7.7.4 + recast: 0.23.4 tunnel: 0.0.6 - uuid: 11.1.0 + uuid: 9.0.0 transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' - '@types/node' - - bare-abort-controller - bufferutil - debug - - react-native-b4a - supports-color - typescript - utf-8-validate @@ -13698,8 +13841,8 @@ packages: zod: 4.3.5 dev: true - /ci-info@4.4.0: - resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + /ci-info@3.8.0: + resolution: {integrity: sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==} engines: {node: '>=8'} dev: true @@ -13774,6 +13917,13 @@ packages: restore-cursor: 5.1.0 dev: false + /cli-progress@3.12.0: + resolution: {integrity: sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==} + engines: {node: '>=4'} + dependencies: + string-width: 4.2.3 + dev: true + /cli-spinners@2.9.2: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} @@ -14053,13 +14203,6 @@ packages: resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} dev: false - /config-chain@1.1.13: - resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} - dependencies: - ini: 1.3.8 - proto-list: 1.2.4 - dev: true - /consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -14957,6 +15100,13 @@ packages: engines: {node: '>=0.3.1'} dev: false + /dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + dependencies: + path-type: 4.0.0 + dev: true + /dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} @@ -15077,6 +15227,11 @@ packages: engines: {node: '>=12'} dev: true + /dotenv@16.3.1: + resolution: {integrity: sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==} + engines: {node: '>=12'} + dev: true + /dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -16327,9 +16482,9 @@ packages: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} dev: false - /fast-unique-numbers@9.0.26: - resolution: {integrity: sha512-3Mtq8p1zQinjGyWfKeuBunbuFoixG72AUkk4VvzbX4ykCW9Q4FzRaNyIlfQhUjnKw2ARVP+/CKnoyr6wfHftig==} - engines: {node: '>=18.2.0'} + /fast-unique-numbers@8.0.13: + resolution: {integrity: sha512-7OnTFAVPefgw2eBJ1xj2PGGR9FwYzSUso9decayHgCDX4sJkHLdcsYTytTg+tYv+wKF3U8gJuSBz2jJpQV4u/g==} + engines: {node: '>=16.1.0'} dependencies: '@babel/runtime': 7.28.6 tslib: 2.8.1 @@ -17140,6 +17295,19 @@ packages: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} dev: false + /glob@10.3.1: + resolution: {integrity: sha512-9BKYcEeIs7QwlCYs+Y3GBvqAMISufUS0i2ELd11zpZjxI5V9iyRj0HgzB5/cLf2NY4vcYBTYzJ7GIui7j/4DOw==} + engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + dependencies: + foreground-child: 3.3.1 + jackspeak: 2.3.6 + minimatch: 9.0.5 + minipass: 5.0.0 + path-scurry: 1.11.1 + dev: true + /glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -17221,6 +17389,18 @@ packages: define-properties: 1.2.1 gopd: 1.2.0 + /globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + dev: true + /gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -17259,10 +17439,6 @@ packages: responselike: 3.0.0 dev: true - /graceful-fs@4.2.10: - resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} - dev: true - /graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -17724,6 +17900,11 @@ packages: ms: 2.1.3 dev: false + /hyperlinker@1.0.0: + resolution: {integrity: sha512-Ty8UblRWFEcfSuIaajM34LdPXIhbs1ajEX/BBPv24J+enSVaEVY63xQ6lTO9VRYS5LAoghIG0IDJ+p+IPzKUQQ==} + engines: {node: '>=4'} + dev: true + /ico-endec@0.1.6: resolution: {integrity: sha512-ZdLU38ZoED3g1j3iEyzcQj+wAkY2xfWNkymszfJPoxucIUhK7NayQ+/C4Kv0nDFMIsbtbEHldv3V8PU494/ueQ==} dev: true @@ -18030,7 +18211,6 @@ packages: dependencies: call-bound: 1.0.4 has-tostringtag: 1.0.2 - dev: false /is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} @@ -18196,6 +18376,14 @@ packages: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} + /is-nan@1.3.2: + resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + dev: true + /is-negative-zero@2.0.3: resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} engines: {node: '>= 0.4'} @@ -18408,6 +18596,15 @@ packages: set-function-name: 2.0.2 dev: true + /jackspeak@2.3.6: + resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} + engines: {node: '>=14'} + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + dev: true + /jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} dependencies: @@ -19001,8 +19198,8 @@ packages: react: 18.3.1 dev: false - /luxon@3.7.2: - resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + /luxon@3.3.0: + resolution: {integrity: sha512-An0UCfG/rSiqtAIiBPO0Y9/zAnHUZxAMiCpTd5h2smgsj7GGmcenvrvww2cqNA8/4A5ZrD1gJpHN2mIHZQF+Mg==} engines: {node: '>=12'} dev: true @@ -19741,6 +19938,13 @@ packages: brace-expansion: 2.0.2 dev: true + /minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.2 + dev: true + /minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -19865,8 +20069,8 @@ packages: - supports-color dev: true - /mqtt@5.15.0: - resolution: {integrity: sha512-KC+wAssYk83Qu5bT8YDzDYgUJxPhbLeVsDvpY2QvL28PnXYJzC2WkKruyMUgBAZaQ7h9lo9k2g4neRNUUxzgMw==} + /mqtt@5.10.1: + resolution: {integrity: sha512-hXCOki8sANoQ7w+2OzJzg6qMBxTtrH9RlnVNV8panLZgnl+Gh0J/t4k6r8Az8+C7y3KAcyXtn0mmLixyUom8Sw==} engines: {node: '>=16.0.0'} hasBin: true dependencies: @@ -19881,10 +20085,10 @@ packages: mqtt-packet: 9.0.2 number-allocator: 1.0.14 readable-stream: 4.7.0 + reinterval: 1.1.0 rfdc: 1.4.1 - socks: 2.8.7 split2: 4.2.0 - worker-timers: 8.0.30 + worker-timers: 7.1.8 ws: 8.19.0 transitivePeerDependencies: - bufferutil @@ -19972,6 +20176,10 @@ packages: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true + /natural-orderby@2.0.3: + resolution: {integrity: sha512-p7KTHxU0CUrcOXe62Zfrb5Z13nLvPhSWR/so3kFulUQU0sgUll2Z0LwpsLN351eOOD+hRGu/F1g+6xDfPeD++Q==} + dev: true + /negotiator@0.6.3: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} @@ -20390,12 +20598,16 @@ packages: dependencies: call-bind: 1.0.8 define-properties: 1.2.1 - dev: false /object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} + /object-treeify@1.1.33: + resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} + engines: {node: '>= 10'} + dev: true + /object-treeify@4.0.1: resolution: {integrity: sha512-Y6tg5rHfsefSkfKujv2SwHulInROy/rCL5F4w0QOWxut8AnxYxf0YmNhTh95Zfyxpsudo66uqkux0ACFnyMSgQ==} engines: {node: '>= 16'} @@ -20502,6 +20714,15 @@ packages: regex: 6.1.0 regex-recursion: 6.0.2 + /open@8.4.0: + resolution: {integrity: sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==} + engines: {node: '>=12'} + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + dev: true + /open@8.4.2: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} @@ -20806,6 +21027,13 @@ packages: engines: {node: '>= 0.8'} dev: true + /password-prompt@1.1.3: + resolution: {integrity: sha512-HkrjG2aJlvF0t2BMH0e2LB/EHf3Lcq3fNMzy4GYHcQblAvOl+QQji1Lx7WRBMqpVK8p+KR7bCg7oqAMXtdgqyw==} + dependencies: + ansi-escapes: 4.3.2 + cross-spawn: 7.0.6 + dev: true + /patch-console@2.0.0: resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -20862,6 +21090,11 @@ packages: resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} dev: true + /path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + dev: true + /pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} dev: true @@ -21215,10 +21448,6 @@ packages: /property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} - /proto-list@1.2.4: - resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} - dev: true - /protobufjs@7.5.4: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} @@ -21833,14 +22062,14 @@ packages: engines: {node: '>= 14.18.0'} dev: false - /recast@0.23.11: - resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} + /recast@0.23.4: + resolution: {integrity: sha512-qtEDqIZGVcSZCHniWwZWbRy79Dc6Wp3kT/UmDA2RJKBPg7+7k51aQBZirHmUGn5uvHf2rg8DkjizrN26k61ATw==} engines: {node: '>= 4'} dependencies: + assert: 2.1.0 ast-types: 0.16.1 esprima: 4.0.1 source-map: 0.6.1 - tiny-invariant: 1.3.3 tslib: 2.8.1 dev: true @@ -21906,6 +22135,12 @@ packages: unified: 11.0.5 vfile: 6.0.3 + /redeyed@2.1.1: + resolution: {integrity: sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ==} + dependencies: + esprima: 4.0.1 + dev: true + /redux-thunk@3.1.0(redux@5.0.1): resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} peerDependencies: @@ -21972,13 +22207,6 @@ packages: gopd: 1.2.0 set-function-name: 2.0.2 - /registry-auth-token@5.1.1: - resolution: {integrity: sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==} - engines: {node: '>=14'} - dependencies: - '@pnpm/npm-conf': 3.0.2 - dev: true - /rehype-katex@7.0.1: resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} dependencies: @@ -22023,6 +22251,10 @@ packages: unified: 11.0.5 dev: true + /reinterval@1.1.0: + resolution: {integrity: sha512-QIRet3SYrGp0HUHO88jVskiG6seqUGC5iAG7AwI/BV4ypGcuqk9Du6YQBUOUqm9c8pw1eyLoIaONifRua1lsEQ==} + dev: true + /remark-frontmatter@5.0.0: resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==} dependencies: @@ -22905,6 +23137,11 @@ packages: resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==} dev: false + /slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + dev: true + /slice-ansi@5.0.0: resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} engines: {node: '>=12'} @@ -23483,6 +23720,14 @@ packages: dependencies: has-flag: 4.0.0 + /supports-hyperlinks@2.3.0: + resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==} + engines: {node: '>=8'} + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + dev: true + /supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -23844,6 +24089,7 @@ packages: /tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + dev: false /tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -23992,11 +24238,11 @@ packages: - zod dev: false - /ts-api-utils@2.4.0(typescript@5.5.3): - resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} - engines: {node: '>=18.12'} + /ts-api-utils@1.4.3(typescript@5.5.3): + resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} + engines: {node: '>=16'} peerDependencies: - typescript: '>=4.8.4' + typescript: '>=4.2.0' dependencies: typescript: 5.5.3 dev: true @@ -24736,6 +24982,16 @@ packages: /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + /util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + dependencies: + inherits: 2.0.4 + is-arguments: 1.2.0 + is-generator-function: 1.1.2 + is-typed-array: 1.1.15 + which-typed-array: 1.1.20 + dev: true + /utility-types@3.11.0: resolution: {integrity: sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==} engines: {node: '>= 4'} @@ -24760,6 +25016,11 @@ packages: hasBin: true dev: false + /uuid@9.0.0: + resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==} + hasBin: true + dev: true + /uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -25526,39 +25787,29 @@ packages: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} dev: true - /worker-factory@7.0.48: - resolution: {integrity: sha512-CGmBy3tJvpBPjUvb0t4PrpKubUsfkI1Ohg0/GGFU2RvA9j/tiVYwKU8O7yu7gH06YtzbeJLzdUR29lmZKn5pag==} - dependencies: - '@babel/runtime': 7.28.6 - fast-unique-numbers: 9.0.26 - tslib: 2.8.1 - dev: true - - /worker-timers-broker@8.0.15: - resolution: {integrity: sha512-Te+EiVUMzG5TtHdmaBZvBrZSFNauym6ImDaCAnzQUxvjnw+oGjMT2idmAOgDy30vOZMLejd0bcsc90Axu6XPWA==} + /worker-timers-broker@6.1.8: + resolution: {integrity: sha512-FUCJu9jlK3A8WqLTKXM9E6kAmI/dR1vAJ8dHYLMisLNB/n3GuaFIjJ7pn16ZcD1zCOf7P6H62lWIEBi+yz/zQQ==} dependencies: '@babel/runtime': 7.28.6 - broker-factory: 3.1.13 - fast-unique-numbers: 9.0.26 + fast-unique-numbers: 8.0.13 tslib: 2.8.1 - worker-timers-worker: 9.0.13 + worker-timers-worker: 7.0.71 dev: true - /worker-timers-worker@9.0.13: - resolution: {integrity: sha512-qjn18szGb1kjcmh2traAdki1eiIS5ikFo+L90nfMOvSRpuDw1hAcR1nzkP2+Hkdqz5thIRnfuWx7QSpsEUsA6Q==} + /worker-timers-worker@7.0.71: + resolution: {integrity: sha512-ks/5YKwZsto1c2vmljroppOKCivB/ma97g9y77MAAz2TBBjPPgpoOiS1qYQKIgvGTr2QYPT3XhJWIB6Rj2MVPQ==} dependencies: '@babel/runtime': 7.28.6 tslib: 2.8.1 - worker-factory: 7.0.48 dev: true - /worker-timers@8.0.30: - resolution: {integrity: sha512-8P7YoMHWN0Tz7mg+9oEhuZdjBIn2z6gfjlJqFcHiDd9no/oLnMGCARCDkV1LR3ccQus62ZdtIp7t3aTKrMLHOg==} + /worker-timers@7.1.8: + resolution: {integrity: sha512-R54psRKYVLuzff7c1OTFcq/4Hue5Vlz4bFtNEIarpSiCYhpifHU3aIQI29S84o1j87ePCYqbmEJPqwBTf+3sfw==} dependencies: '@babel/runtime': 7.28.6 tslib: 2.8.1 - worker-timers-broker: 8.0.15 - worker-timers-worker: 9.0.13 + worker-timers-broker: 6.1.8 + worker-timers-worker: 7.0.71 dev: true /wrap-ansi@6.2.0: