mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-13 18:05:31 +02:00
* fix(migrate-auth-secret): exit cleanly when there are no 2FA records The empty-records branch of `main()` returned without calling `process.exit(0)`, leaving the Drizzle Postgres connection pool holding the event loop open. The `migrate-auth-secret` process then hangs indefinitely after printing "No 2FA records found, nothing to migrate." causing the upstream `0.29.3.sh` security migration script (which calls this via `docker exec`) to never reach its final `docker service update` step that mounts the new Docker Secret. Operators end up with the new secret created but the dokploy service still configured with the hardcoded `BETTER_AUTH_SECRET`, while believing the migration completed. Match the success branch a few lines below which already does `process.exit(0)`, and the pattern used in sibling scripts `reset-password.ts` and `reset-2fa.ts`. Closes #4392 * feat(compose): add import from base64 in create service dropdown Adds an "Import" option to the Create Service dropdown that lets users paste a base64-encoded compose export, preview the template (compose YAML, domains, envs, mounts) before confirming, and create the service only on confirm. Adds a `previewTemplate` tRPC procedure that processes the base64 without touching the DB, with server access validation via session. * [autofix.ci] apply automated fixes * Enhance version synchronization workflow to include SDK repository - Updated the GitHub Actions workflow to sync versioning across MCP, CLI, and SDK repositories. - Added steps to bump the version in the SDK repository and regenerate tools from the latest OpenAPI spec. - Improved commit message formatting to include source and release information for all repositories. - Ensured successful synchronization messages for each repository after the version update. * feat(deployment): add readLogs procedure to fetch deployment logs - Introduced a new `readLogs` procedure that allows users to retrieve logs for a specific deployment by providing the deployment ID and an optional tail parameter. - Implemented permission checks to ensure users have access to the requested logs. - Enhanced log retrieval for both cloud and non-cloud environments, utilizing appropriate commands based on the server context. Resolve https://github.com/Dokploy/mcp/issues/14 * feat(deployment): add server access validation for deployment actions - Implemented server access validation in deployment procedures to ensure users can only access deployments associated with their active organization. - Added checks to throw an UNAUTHORIZED error if a user attempts to access a deployment linked to a server outside their organization. This enhancement improves security and access control within the deployment management system. * feat(organization): prevent inviting users with owner role - Added validation to prevent users from being invited with the owner role in the organization and user routers. - Implemented TRPCError responses to ensure proper error handling when attempting to assign the owner role. This change enhances role management and security within the organization structure. https://github.com/Dokploy/dokploy/security/advisories/GHSA-fm9p-wmpw-gxjh * feat(user): implement session cleanup on user update - Added functionality to delete old sessions when a user updates their password, ensuring that only the current session remains active. - This change enhances security by preventing unauthorized access from previous sessions after a password change. Close here https://github.com/Dokploy/dokploy/security/advisories/GHSA-rr9m-w87g-46f3 * feat(settings): add copy button to server IP in web server settings (#4397) * fix: copy Dokploy server IP when clicking server badge (#4390) * fix: copy Dokploy server IP when clicking server badge When a service runs on the local Dokploy server (no remote server), clicking the server badge did nothing because `data.server` is null. Now falls back to the server IP from settings so the badge always copies an IP address. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(copy-ip): implement IP address copying functionality across database service components - Added the ability to copy the server IP address to the clipboard when clicking the server badge in various database service components (Libsql, MariaDB, MongoDB, MySQL, PostgreSQL, Redis). - Integrated the `copy-to-clipboard` library and `sonner` for user feedback upon successful copy action. - Ensured fallback to the server IP from settings when the service data is not available, enhancing user experience and functionality. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Mauricio Siu <siumauricio@icloud.com> * fix: responsive layout (#4391) Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com> * fix: automatically converting username to lowercase both in creation of register, and build for extra. (#4382) * fix: allow square brackets in zip path validation for Next.js dynamic routes (#4468) * fix: allow square brackets in zip drop path validation for Next.js dynamic routes ZIP uploads containing Next.js dynamic route files (e.g. app/api/[id]/route.ts, pages/[slug].tsx) were rejected by readValidDirectory because the path regex did not include square bracket characters. * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> * fix: prevent webhook deploy crash when commit data lacks modified files (#4470) shouldDeploy passed undefined/null entries from commit.modified straight into micromatch, which throws "Expected input to be a string" and fails every webhook deployment when watch paths are configured. Filter out non-string values before matching. * fix: add type="button" to TooltipTrigger in form components to prevent accidental submission (#4422) Co-authored-by: Maks Pikov <mixelburg@users.noreply.github.com> * fix: enable comment toggle shortcut in env variable editor (#4402) (#4473) * fix: add tls=true label for domains when certificateType is none (#4018) (#4474) * fix: add tls=true label for compose domains when certificateType is none (#4018) * test: cover tls=true label for certificateType none, require https * fix: scope tls fix to compose labels, leave traefik file config unchanged (#4018) * chore: update version to v0.29.5 in package.json --------- Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com> Co-authored-by: ngenohkevin <ngenohkevin19@gmail.com> Co-authored-by: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Co-authored-by: Mauricio Siu <siumauricio@icloud.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Volodymyr Kravchuk <volodymyr.kravch@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Nahidujjaman Hridoy <75487507+nhridoy@users.noreply.github.com> Co-authored-by: Francis <9560564+Baker@users.noreply.github.com> Co-authored-by: mixelburg <52622705+mixelburg@users.noreply.github.com> Co-authored-by: Maks Pikov <mixelburg@users.noreply.github.com>
534 lines
16 KiB
TypeScript
534 lines
16 KiB
TypeScript
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
|
import { InfoIcon, Plus, Trash2 } from "lucide-react";
|
|
import { useEffect } from "react";
|
|
import { useFieldArray, useForm } from "react-hook-form";
|
|
import { toast } from "sonner";
|
|
import { z } from "zod";
|
|
import { AlertBlock } from "@/components/shared/alert-block";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from "@/components/ui/form";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
createConverter,
|
|
NumberInputWithSteps,
|
|
} from "@/components/ui/number-input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import { api } from "@/utils/api";
|
|
|
|
const CPU_STEP = 0.25;
|
|
const MEMORY_STEP_MB = 256;
|
|
|
|
const formatNumber = (value: number, decimals = 2): string =>
|
|
Number.isInteger(value) ? String(value) : value.toFixed(decimals);
|
|
|
|
const cpuConverter = createConverter(1_000_000_000, (cpu) =>
|
|
cpu <= 0 ? "" : `${formatNumber(cpu)} CPU`,
|
|
);
|
|
|
|
const memoryConverter = createConverter(1024 * 1024, (mb) => {
|
|
if (mb <= 0) return "";
|
|
return mb >= 1024
|
|
? `${formatNumber(mb / 1024)} GB`
|
|
: `${formatNumber(mb)} MB`;
|
|
});
|
|
|
|
const ulimitSchema = z.object({
|
|
Name: z.string().min(1, "Name is required"),
|
|
Soft: z.coerce.number().int().min(-1, "Must be >= -1"),
|
|
Hard: z.coerce.number().int().min(-1, "Must be >= -1"),
|
|
});
|
|
|
|
const addResourcesSchema = z.object({
|
|
memoryReservation: z.string().optional(),
|
|
cpuLimit: z.string().optional(),
|
|
memoryLimit: z.string().optional(),
|
|
cpuReservation: z.string().optional(),
|
|
ulimitsSwarm: z.array(ulimitSchema).optional(),
|
|
});
|
|
|
|
const ULIMIT_PRESETS = [
|
|
{ value: "nofile", label: "nofile (Open Files)" },
|
|
{ value: "nproc", label: "nproc (Processes)" },
|
|
{ value: "memlock", label: "memlock (Locked Memory)" },
|
|
{ value: "stack", label: "stack (Stack Size)" },
|
|
{ value: "core", label: "core (Core File Size)" },
|
|
{ value: "cpu", label: "cpu (CPU Time)" },
|
|
{ value: "data", label: "data (Data Segment)" },
|
|
{ value: "fsize", label: "fsize (File Size)" },
|
|
{ value: "locks", label: "locks (File Locks)" },
|
|
{ value: "msgqueue", label: "msgqueue (Message Queues)" },
|
|
{ value: "nice", label: "nice (Nice Priority)" },
|
|
{ value: "rtprio", label: "rtprio (Real-time Priority)" },
|
|
{ value: "sigpending", label: "sigpending (Pending Signals)" },
|
|
];
|
|
|
|
export type ServiceType =
|
|
| "application"
|
|
| "libsql"
|
|
| "mariadb"
|
|
| "mongo"
|
|
| "mysql"
|
|
| "postgres"
|
|
| "redis";
|
|
|
|
interface Props {
|
|
id: string;
|
|
type: ServiceType | "application";
|
|
}
|
|
|
|
type AddResources = z.infer<typeof addResourcesSchema>;
|
|
|
|
export const ShowResources = ({ id, type }: Props) => {
|
|
const queryMap = {
|
|
application: () =>
|
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
|
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
mariadb: () =>
|
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
|
postgres: () =>
|
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
|
};
|
|
const { data, refetch } = queryMap[type]
|
|
? queryMap[type]()
|
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
|
|
|
const mutationMap = {
|
|
application: () => api.application.update.useMutation(),
|
|
libsql: () => api.libsql.update.useMutation(),
|
|
mariadb: () => api.mariadb.update.useMutation(),
|
|
mongo: () => api.mongo.update.useMutation(),
|
|
mysql: () => api.mysql.update.useMutation(),
|
|
postgres: () => api.postgres.update.useMutation(),
|
|
redis: () => api.redis.update.useMutation(),
|
|
};
|
|
|
|
const { mutateAsync, isPending } = mutationMap[type]
|
|
? mutationMap[type]()
|
|
: api.mongo.update.useMutation();
|
|
|
|
const form = useForm({
|
|
defaultValues: {
|
|
cpuLimit: "",
|
|
cpuReservation: "",
|
|
memoryLimit: "",
|
|
memoryReservation: "",
|
|
ulimitsSwarm: [],
|
|
},
|
|
resolver: zodResolver(addResourcesSchema),
|
|
});
|
|
|
|
const { fields, append, remove } = useFieldArray({
|
|
control: form.control,
|
|
name: "ulimitsSwarm",
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (data) {
|
|
form.reset({
|
|
cpuLimit: data?.cpuLimit || undefined,
|
|
cpuReservation: data?.cpuReservation || undefined,
|
|
memoryLimit: data?.memoryLimit || undefined,
|
|
memoryReservation: data?.memoryReservation || undefined,
|
|
ulimitsSwarm: (data as any)?.ulimitsSwarm || [],
|
|
});
|
|
}
|
|
}, [data, form, form.reset]);
|
|
|
|
const onSubmit = async (formData: AddResources) => {
|
|
await mutateAsync({
|
|
applicationId: id || "",
|
|
libsqlId: id || "",
|
|
mariadbId: id || "",
|
|
mongoId: id || "",
|
|
mysqlId: id || "",
|
|
postgresId: id || "",
|
|
redisId: id || "",
|
|
cpuLimit: formData.cpuLimit || null,
|
|
cpuReservation: formData.cpuReservation || null,
|
|
memoryLimit: formData.memoryLimit || null,
|
|
memoryReservation: formData.memoryReservation || null,
|
|
ulimitsSwarm:
|
|
formData.ulimitsSwarm && formData.ulimitsSwarm.length > 0
|
|
? formData.ulimitsSwarm
|
|
: null,
|
|
})
|
|
.then(async () => {
|
|
toast.success("Resources Updated");
|
|
await refetch();
|
|
})
|
|
.catch(() => {
|
|
toast.error("Error updating the resources");
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Card className="bg-background">
|
|
<CardHeader>
|
|
<CardTitle className="text-xl">Resources</CardTitle>
|
|
<CardDescription>
|
|
If you want to decrease or increase the resources to a specific.
|
|
application or database
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-4">
|
|
<AlertBlock type="info">
|
|
Please remember to click Redeploy after modify the resources to apply
|
|
the changes.
|
|
</AlertBlock>
|
|
<Form {...form}>
|
|
<form
|
|
id="hook-form"
|
|
onSubmit={form.handleSubmit(onSubmit)}
|
|
className="grid w-full gap-8 "
|
|
>
|
|
<div className="grid w-full md:grid-cols-2 gap-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="memoryLimit"
|
|
render={({ field }) => {
|
|
return (
|
|
<FormItem>
|
|
<div
|
|
className="flex items-center gap-2"
|
|
onClick={(e) => e.preventDefault()}
|
|
>
|
|
<FormLabel>Memory Limit</FormLabel>
|
|
<TooltipProvider>
|
|
<Tooltip delayDuration={0}>
|
|
<TooltipTrigger type="button">
|
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>
|
|
Memory hard limit in bytes. Example: 1GB =
|
|
1073741824 bytes. Use +/- buttons to adjust by
|
|
256 MB.
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
<FormControl>
|
|
<NumberInputWithSteps
|
|
value={field.value}
|
|
onChange={field.onChange}
|
|
placeholder="1073741824 (1GB in bytes)"
|
|
step={MEMORY_STEP_MB}
|
|
converter={memoryConverter}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
);
|
|
}}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="memoryReservation"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<div
|
|
className="flex items-center gap-2"
|
|
onClick={(e) => e.preventDefault()}
|
|
>
|
|
<FormLabel>Memory Reservation</FormLabel>
|
|
<TooltipProvider>
|
|
<Tooltip delayDuration={0}>
|
|
<TooltipTrigger type="button">
|
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>
|
|
Memory soft limit in bytes. Example: 256MB =
|
|
268435456 bytes. Use +/- buttons to adjust by 256
|
|
MB.
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
<FormControl>
|
|
<NumberInputWithSteps
|
|
value={field.value}
|
|
onChange={field.onChange}
|
|
placeholder="268435456 (256MB in bytes)"
|
|
step={MEMORY_STEP_MB}
|
|
converter={memoryConverter}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="cpuLimit"
|
|
render={({ field }) => {
|
|
return (
|
|
<FormItem>
|
|
<div
|
|
className="flex items-center gap-2"
|
|
onClick={(e) => e.preventDefault()}
|
|
>
|
|
<FormLabel>CPU Limit</FormLabel>
|
|
<TooltipProvider>
|
|
<Tooltip delayDuration={0}>
|
|
<TooltipTrigger type="button">
|
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>
|
|
CPU quota in units of 10^-9 CPUs. Example: 2
|
|
CPUs = 2000000000. Use +/- buttons to adjust by
|
|
0.25 CPU.
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
<FormControl>
|
|
<NumberInputWithSteps
|
|
value={field.value}
|
|
onChange={field.onChange}
|
|
placeholder="2000000000 (2 CPUs)"
|
|
step={CPU_STEP}
|
|
converter={cpuConverter}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
);
|
|
}}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="cpuReservation"
|
|
render={({ field }) => {
|
|
return (
|
|
<FormItem>
|
|
<div
|
|
className="flex items-center gap-2"
|
|
onClick={(e) => e.preventDefault()}
|
|
>
|
|
<FormLabel>CPU Reservation</FormLabel>
|
|
<TooltipProvider>
|
|
<Tooltip delayDuration={0}>
|
|
<TooltipTrigger type="button">
|
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>
|
|
CPU shares (relative weight). Example: 1 CPU =
|
|
1000000000. Use +/- buttons to adjust by 0.25
|
|
CPU.
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
<FormControl>
|
|
<NumberInputWithSteps
|
|
value={field.value}
|
|
onChange={field.onChange}
|
|
placeholder="1000000000 (1 CPU)"
|
|
step={CPU_STEP}
|
|
converter={cpuConverter}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Ulimits Section */}
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<FormLabel className="text-base">Ulimits</FormLabel>
|
|
<TooltipProvider>
|
|
<Tooltip delayDuration={0}>
|
|
<TooltipTrigger type="button">
|
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
|
</TooltipTrigger>
|
|
<TooltipContent className="max-w-xs">
|
|
<p>
|
|
Set resource limits for the container. Each ulimit has
|
|
a soft limit (warning threshold) and hard limit
|
|
(maximum allowed). Use -1 for unlimited.
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() =>
|
|
append({ Name: "nofile", Soft: 65535, Hard: 65535 })
|
|
}
|
|
>
|
|
<Plus className="h-4 w-4 mr-1" />
|
|
Add Ulimit
|
|
</Button>
|
|
</div>
|
|
|
|
{fields.length > 0 && (
|
|
<div className="space-y-3">
|
|
{fields.map((field, index) => (
|
|
<div
|
|
key={field.id}
|
|
className="flex items-start gap-3 p-3 border rounded-lg bg-muted/30"
|
|
>
|
|
<FormField
|
|
control={form.control}
|
|
name={`ulimitsSwarm.${index}.Name`}
|
|
render={({ field }) => (
|
|
<FormItem className="flex-1">
|
|
<FormLabel className="text-xs">Type</FormLabel>
|
|
<Select
|
|
onValueChange={field.onChange}
|
|
value={field.value}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select ulimit" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
{ULIMIT_PRESETS.map((preset) => (
|
|
<SelectItem
|
|
key={preset.value}
|
|
value={preset.value}
|
|
>
|
|
{preset.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name={`ulimitsSwarm.${index}.Soft`}
|
|
render={({ field }) => (
|
|
<FormItem className="w-32">
|
|
<FormLabel className="text-xs">
|
|
Soft Limit
|
|
</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
type="number"
|
|
min={-1}
|
|
placeholder="65535"
|
|
{...field}
|
|
value={
|
|
typeof field.value === "number"
|
|
? field.value
|
|
: ""
|
|
}
|
|
onChange={(e) =>
|
|
field.onChange(Number(e.target.value))
|
|
}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name={`ulimitsSwarm.${index}.Hard`}
|
|
render={({ field }) => (
|
|
<FormItem className="w-32">
|
|
<FormLabel className="text-xs">
|
|
Hard Limit
|
|
</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
type="number"
|
|
min={-1}
|
|
placeholder="65535"
|
|
{...field}
|
|
value={
|
|
typeof field.value === "number"
|
|
? field.value
|
|
: ""
|
|
}
|
|
onChange={(e) =>
|
|
field.onChange(Number(e.target.value))
|
|
}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="mt-6 text-destructive hover:text-destructive"
|
|
onClick={() => remove(index)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{fields.length === 0 && (
|
|
<p className="text-sm text-muted-foreground">
|
|
No ulimits configured. Click "Add Ulimit" to set
|
|
resource limits.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex w-full justify-end">
|
|
<Button isLoading={isPending} type="submit">
|
|
Save
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Form>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|