mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-05 14:05:30 +02:00
Merge branch 'canary' into bitbucket-api-token
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import jsyaml from "js-yaml";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { parse, stringify, YAMLParseError } from "yaml";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
@@ -38,11 +38,11 @@ interface Props {
|
||||
|
||||
export const validateAndFormatYAML = (yamlText: string) => {
|
||||
try {
|
||||
const obj = jsyaml.load(yamlText);
|
||||
const formattedYaml = jsyaml.dump(obj, { indent: 4 });
|
||||
const obj = parse(yamlText);
|
||||
const formattedYaml = stringify(obj, { indent: 4 });
|
||||
return { valid: true, formattedYaml, error: null };
|
||||
} catch (error) {
|
||||
if (error instanceof jsyaml.YAMLException) {
|
||||
if (error instanceof YAMLParseError) {
|
||||
return {
|
||||
valid: false,
|
||||
formattedYaml: yamlText,
|
||||
@@ -89,7 +89,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: error || "Invalid YAML",
|
||||
message: (error as string) || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
@@ -61,12 +62,48 @@ export const ShowDeployments = ({
|
||||
},
|
||||
);
|
||||
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
|
||||
const { mutateAsync: rollback, isLoading: isRollingBack } =
|
||||
api.rollback.rollback.useMutation();
|
||||
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
|
||||
api.deployment.killProcess.useMutation();
|
||||
|
||||
// Cancel deployment mutations
|
||||
const {
|
||||
mutateAsync: cancelApplicationDeployment,
|
||||
isLoading: isCancellingApp,
|
||||
} = api.application.cancelDeployment.useMutation();
|
||||
const {
|
||||
mutateAsync: cancelComposeDeployment,
|
||||
isLoading: isCancellingCompose,
|
||||
} = api.compose.cancelDeployment.useMutation();
|
||||
|
||||
const [url, setUrl] = React.useState("");
|
||||
|
||||
// Check for stuck deployment (more than 9 minutes) - only for the most recent deployment
|
||||
const stuckDeployment = useMemo(() => {
|
||||
if (!isCloud || !deployments || deployments.length === 0) return null;
|
||||
|
||||
const now = Date.now();
|
||||
const NINE_MINUTES = 10 * 60 * 1000; // 9 minutes in milliseconds
|
||||
|
||||
// Get the most recent deployment (first in the list since they're sorted by date)
|
||||
const mostRecentDeployment = deployments[0];
|
||||
|
||||
if (
|
||||
!mostRecentDeployment ||
|
||||
mostRecentDeployment.status !== "running" ||
|
||||
!mostRecentDeployment.startedAt
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startTime = new Date(mostRecentDeployment.startedAt).getTime();
|
||||
const elapsed = now - startTime;
|
||||
|
||||
return elapsed > NINE_MINUTES ? mostRecentDeployment : null;
|
||||
}, [isCloud, deployments]);
|
||||
useEffect(() => {
|
||||
setUrl(document.location.origin);
|
||||
}, []);
|
||||
@@ -94,6 +131,54 @@ export const ShowDeployments = ({
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{stuckDeployment && (type === "application" || type === "compose") && (
|
||||
<AlertBlock
|
||||
type="warning"
|
||||
className="flex-col items-start w-full p-4"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-sm mb-1">
|
||||
Build appears to be stuck
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
Hey! Looks like the build has been running for more than 10
|
||||
minutes. Would you like to cancel this deployment?
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
isLoading={
|
||||
type === "application" ? isCancellingApp : isCancellingCompose
|
||||
}
|
||||
onClick={async () => {
|
||||
try {
|
||||
if (type === "application") {
|
||||
await cancelApplicationDeployment({
|
||||
applicationId: id,
|
||||
});
|
||||
} else if (type === "compose") {
|
||||
await cancelComposeDeployment({
|
||||
composeId: id,
|
||||
});
|
||||
}
|
||||
toast.success("Deployment cancellation requested");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to cancel deployment",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancel Deployment
|
||||
</Button>
|
||||
</div>
|
||||
</AlertBlock>
|
||||
)}
|
||||
{refreshToken && (
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<span>
|
||||
|
||||
@@ -102,9 +102,9 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
In order to make the database reachable through the internet, you
|
||||
must set a port and ensure that the port is not being used by
|
||||
another application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
|
||||
@@ -102,9 +102,9 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
In order to make the database reachable through the internet, you
|
||||
must set a port and ensure that the port is not being used by
|
||||
another application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
|
||||
@@ -102,9 +102,9 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
In order to make the database reachable through the internet, you
|
||||
must set a port and ensure that the port is not being used by
|
||||
another application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
|
||||
@@ -104,9 +104,9 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
In order to make the database reachable through the internet, you
|
||||
must set a port and ensure that the port is not being used by
|
||||
another application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
|
||||
@@ -76,6 +76,10 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
|
||||
const hasServers = servers && servers.length > 0;
|
||||
// Show dropdown logic based on cloud environment
|
||||
// Cloud: show only if there are remote servers (no Dokploy option)
|
||||
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
|
||||
const shouldShowServerDropdown = hasServers;
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.application.create.useMutation();
|
||||
@@ -94,8 +98,8 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
||||
name: data.name,
|
||||
appName: data.appName,
|
||||
description: data.description,
|
||||
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
|
||||
environmentId,
|
||||
serverId: data.serverId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Created");
|
||||
@@ -157,7 +161,7 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{hasServers && (
|
||||
{shouldShowServerDropdown && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverId"
|
||||
@@ -186,13 +190,27 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
||||
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
defaultValue={
|
||||
field.value || (!isCloud ? "dokploy" : undefined)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a Server" />
|
||||
<SelectValue
|
||||
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{!isCloud && (
|
||||
<SelectItem value="dokploy">
|
||||
<span className="flex items-center gap-2 justify-between w-full">
|
||||
<span>Dokploy</span>
|
||||
<span className="text-muted-foreground text-xs self-center">
|
||||
Default
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
)}
|
||||
{servers?.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
@@ -206,7 +224,9 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>Servers ({servers?.length})</SelectLabel>
|
||||
<SelectLabel>
|
||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -82,6 +82,10 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
const { data: environment } = api.environment.one.useQuery({ environmentId });
|
||||
|
||||
const hasServers = servers && servers.length > 0;
|
||||
// Show dropdown logic based on cloud environment
|
||||
// Cloud: show only if there are remote servers (no Dokploy option)
|
||||
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
|
||||
const shouldShowServerDropdown = hasServers;
|
||||
|
||||
const form = useForm<AddCompose>({
|
||||
defaultValues: {
|
||||
@@ -104,7 +108,7 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
environmentId,
|
||||
composeType: data.composeType,
|
||||
appName: data.appName,
|
||||
serverId: data.serverId,
|
||||
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Compose Created");
|
||||
@@ -169,7 +173,7 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{hasServers && (
|
||||
{shouldShowServerDropdown && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverId"
|
||||
@@ -198,13 +202,27 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
defaultValue={
|
||||
field.value || (!isCloud ? "dokploy" : undefined)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a Server" />
|
||||
<SelectValue
|
||||
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{!isCloud && (
|
||||
<SelectItem value="dokploy">
|
||||
<span className="flex items-center gap-2 justify-between w-full">
|
||||
<span>Dokploy</span>
|
||||
<span className="text-muted-foreground text-xs self-center">
|
||||
Default
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
)}
|
||||
{servers?.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
@@ -218,7 +236,9 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>Servers ({servers?.length})</SelectLabel>
|
||||
<SelectLabel>
|
||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -179,6 +179,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const slug = slugify(projectName);
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
const postgresMutation = api.postgres.create.useMutation();
|
||||
const mongoMutation = api.mongo.create.useMutation();
|
||||
@@ -190,6 +191,10 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
const { data: environment } = api.environment.one.useQuery({ environmentId });
|
||||
|
||||
const hasServers = servers && servers.length > 0;
|
||||
// Show dropdown logic based on cloud environment
|
||||
// Cloud: show only if there are remote servers (no Dokploy option)
|
||||
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
|
||||
const shouldShowServerDropdown = hasServers;
|
||||
|
||||
const form = useForm<AddDatabase>({
|
||||
defaultValues: {
|
||||
@@ -223,9 +228,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
name: data.name,
|
||||
appName: data.appName,
|
||||
dockerImage: defaultDockerImage,
|
||||
projectId: environment?.projectId || "",
|
||||
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
|
||||
environmentId,
|
||||
serverId: data.serverId,
|
||||
description: data.description,
|
||||
};
|
||||
|
||||
@@ -237,7 +241,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
|
||||
databaseUser:
|
||||
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
||||
serverId: data.serverId,
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
});
|
||||
} else if (data.type === "mongo") {
|
||||
promise = mongoMutation.mutateAsync({
|
||||
@@ -245,14 +249,14 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
databasePassword: data.databasePassword,
|
||||
databaseUser:
|
||||
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
||||
serverId: data.serverId,
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
replicaSets: data.replicaSets,
|
||||
});
|
||||
} else if (data.type === "redis") {
|
||||
promise = redisMutation.mutateAsync({
|
||||
...commonParams,
|
||||
databasePassword: data.databasePassword,
|
||||
serverId: data.serverId,
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
});
|
||||
} else if (data.type === "mariadb") {
|
||||
promise = mariadbMutation.mutateAsync({
|
||||
@@ -262,7 +266,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
databaseName: data.databaseName || "mariadb",
|
||||
databaseUser:
|
||||
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
||||
serverId: data.serverId,
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
});
|
||||
} else if (data.type === "mysql") {
|
||||
promise = mysqlMutation.mutateAsync({
|
||||
@@ -271,8 +275,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
databaseName: data.databaseName || "mysql",
|
||||
databaseUser:
|
||||
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
databaseRootPassword: data.databaseRootPassword || "",
|
||||
serverId: data.serverId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -403,7 +407,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{hasServers && (
|
||||
{shouldShowServerDropdown && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverId"
|
||||
@@ -412,13 +416,29 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
<FormLabel>Select a Server</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value || ""}
|
||||
defaultValue={
|
||||
field.value || (!isCloud ? "dokploy" : undefined)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a Server" />
|
||||
<SelectValue
|
||||
placeholder={
|
||||
!isCloud ? "Dokploy" : "Select a Server"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{!isCloud && (
|
||||
<SelectItem value="dokploy">
|
||||
<span className="flex items-center gap-2 justify-between w-full">
|
||||
<span>Dokploy</span>
|
||||
<span className="text-muted-foreground text-xs self-center">
|
||||
Default
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
)}
|
||||
{servers?.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
@@ -428,7 +448,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Servers ({servers?.length})
|
||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
|
||||
@@ -141,6 +141,10 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
}) || [];
|
||||
|
||||
const hasServers = servers && servers.length > 0;
|
||||
// Show dropdown logic based on cloud environment
|
||||
// Cloud: show only if there are remote servers (no Dokploy option)
|
||||
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
|
||||
const shouldShowServerDropdown = hasServers;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -167,7 +171,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
<Input
|
||||
placeholder="Search Template"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full sm:w-[200px]"
|
||||
className="w-full"
|
||||
value={query}
|
||||
/>
|
||||
<Input
|
||||
@@ -244,7 +248,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
onClick={() =>
|
||||
setViewMode(viewMode === "detailed" ? "icon" : "detailed")
|
||||
}
|
||||
className="h-9 w-9"
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
>
|
||||
{viewMode === "detailed" ? (
|
||||
<LayoutGrid className="size-4" />
|
||||
@@ -430,7 +434,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
project.
|
||||
</AlertDialogDescription>
|
||||
|
||||
{hasServers && (
|
||||
{shouldShowServerDropdown && (
|
||||
<div>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
@@ -459,12 +463,29 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
onValueChange={(e) => {
|
||||
setServerId(e);
|
||||
}}
|
||||
defaultValue={
|
||||
!isCloud ? "dokploy" : undefined
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a Server" />
|
||||
<SelectValue
|
||||
placeholder={
|
||||
!isCloud ? "Dokploy" : "Select a Server"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{!isCloud && (
|
||||
<SelectItem value="dokploy">
|
||||
<span className="flex items-center gap-2 justify-between w-full">
|
||||
<span>Dokploy</span>
|
||||
<span className="text-muted-foreground text-xs self-center">
|
||||
Default
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
)}
|
||||
{servers?.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
@@ -479,7 +500,8 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Servers ({servers?.length})
|
||||
Servers (
|
||||
{servers?.length + (!isCloud ? 1 : 0)})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
@@ -493,8 +515,11 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
disabled={isLoading}
|
||||
onClick={async () => {
|
||||
const promise = mutateAsync({
|
||||
serverId:
|
||||
serverId === "dokploy"
|
||||
? undefined
|
||||
: serverId,
|
||||
environmentId,
|
||||
serverId: serverId || undefined,
|
||||
id: template.id,
|
||||
baseUrl: customBaseUrl,
|
||||
});
|
||||
|
||||
@@ -25,7 +25,12 @@ const examples = [
|
||||
export const StepOne = ({ setTemplateInfo, templateInfo }: any) => {
|
||||
// Get servers from the API
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const hasServers = servers && servers.length > 0;
|
||||
// Show dropdown logic based on cloud environment
|
||||
// Cloud: show only if there are remote servers (no Dokploy option)
|
||||
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
|
||||
const shouldShowServerDropdown = hasServers;
|
||||
|
||||
const handleExampleClick = (example: string) => {
|
||||
setTemplateInfo({ ...templateInfo, userInput: example });
|
||||
@@ -48,34 +53,58 @@ export const StepOne = ({ setTemplateInfo, templateInfo }: any) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasServers && (
|
||||
{shouldShowServerDropdown && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-deploy">
|
||||
Select the server where you want to deploy (optional)
|
||||
</Label>
|
||||
<Select
|
||||
value={templateInfo.server?.serverId}
|
||||
value={
|
||||
templateInfo.server?.serverId ||
|
||||
(!isCloud ? "dokploy" : undefined)
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
const server = servers?.find((s) => s.serverId === value);
|
||||
if (server) {
|
||||
if (value === "dokploy") {
|
||||
setTemplateInfo({
|
||||
...templateInfo,
|
||||
server: server,
|
||||
server: undefined,
|
||||
});
|
||||
} else {
|
||||
const server = servers?.find((s) => s.serverId === value);
|
||||
if (server) {
|
||||
setTemplateInfo({
|
||||
...templateInfo,
|
||||
server: server,
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a server" />
|
||||
<SelectValue
|
||||
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{!isCloud && (
|
||||
<SelectItem value="dokploy">
|
||||
<span className="flex items-center gap-2 justify-between w-full">
|
||||
<span>Dokploy</span>
|
||||
<span className="text-muted-foreground text-xs self-center">
|
||||
Default
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
)}
|
||||
{servers?.map((server) => (
|
||||
<SelectItem key={server.serverId} value={server.serverId}>
|
||||
{server.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>Servers ({servers?.length})</SelectLabel>
|
||||
<SelectLabel>
|
||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -88,7 +88,7 @@ export const StepThree = ({ templateInfo }: StepProps) => {
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Configuration Files</h3>
|
||||
<ul className="list-disc pl-5">
|
||||
{templateInfo?.details?.configFiles.map((file, index) => (
|
||||
{templateInfo?.details?.configFiles?.map((file, index) => (
|
||||
<li key={index}>
|
||||
<strong className="text-sm font-semibold">
|
||||
{file.filePath}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Bot, Eye, EyeOff, PlusCircle, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Bot, PlusCircle, Trash2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
@@ -27,7 +27,6 @@ export interface StepProps {
|
||||
export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
||||
const suggestions = templateInfo.suggestions || [];
|
||||
const selectedVariant = templateInfo.details;
|
||||
const [showValues, setShowValues] = useState<Record<string, boolean>>({});
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.ai.suggest.useMutation();
|
||||
@@ -44,7 +43,7 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
||||
.then((data) => {
|
||||
setTemplateInfo({
|
||||
...templateInfo,
|
||||
suggestions: data,
|
||||
suggestions: data || [],
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -54,10 +53,6 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
||||
});
|
||||
}, [templateInfo.userInput]);
|
||||
|
||||
const toggleShowValue = (name: string) => {
|
||||
setShowValues((prev) => ({ ...prev, [name]: !prev[name] }));
|
||||
};
|
||||
|
||||
const handleEnvVariableChange = (
|
||||
index: number,
|
||||
field: "name" | "value",
|
||||
@@ -308,11 +303,9 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
||||
placeholder="Variable Name"
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="flex-1 relative">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={
|
||||
showValues[env.name] ? "text" : "password"
|
||||
}
|
||||
type={"password"}
|
||||
value={env.value}
|
||||
onChange={(e) =>
|
||||
handleEnvVariableChange(
|
||||
@@ -323,19 +316,6 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
||||
}
|
||||
placeholder="Variable Value"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2"
|
||||
onClick={() => toggleShowValue(env.name)}
|
||||
>
|
||||
{showValues[env.name] ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -437,13 +417,14 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
||||
<AccordionContent>
|
||||
<ScrollArea className="w-full rounded-md border">
|
||||
<div className="p-4 space-y-4">
|
||||
{selectedVariant?.configFiles?.length > 0 ? (
|
||||
{selectedVariant?.configFiles?.length &&
|
||||
selectedVariant?.configFiles?.length > 0 ? (
|
||||
<>
|
||||
<div className="text-sm text-muted-foreground mb-4">
|
||||
This template requires the following
|
||||
configuration files to be mounted:
|
||||
</div>
|
||||
{selectedVariant.configFiles.map(
|
||||
{selectedVariant?.configFiles?.map(
|
||||
(config, index) => (
|
||||
<div
|
||||
key={index}
|
||||
|
||||
@@ -47,7 +47,7 @@ interface Details {
|
||||
envVariables: EnvVariable[];
|
||||
shortDescription: string;
|
||||
domains: Domain[];
|
||||
configFiles: Mount[];
|
||||
configFiles?: Mount[];
|
||||
}
|
||||
|
||||
interface Mount {
|
||||
|
||||
@@ -80,6 +80,29 @@ export const DuplicateProject = ({
|
||||
api.project.duplicate.useMutation({
|
||||
onSuccess: async (newProject) => {
|
||||
await utils.project.all.invalidate();
|
||||
|
||||
// If duplicating to same project+environment, invalidate the environment query
|
||||
// to refresh the services list
|
||||
if (duplicateType === "existing-environment") {
|
||||
await utils.environment.one.invalidate({
|
||||
environmentId: selectedTargetEnvironment,
|
||||
});
|
||||
await utils.environment.byProjectId.invalidate({
|
||||
projectId: selectedTargetProject,
|
||||
});
|
||||
|
||||
// If duplicating to the same environment we're currently viewing,
|
||||
// also invalidate the current environment to refresh the services list
|
||||
if (selectedTargetEnvironment === environmentId) {
|
||||
await utils.environment.one.invalidate({ environmentId });
|
||||
// Also invalidate the project query to refresh the project data
|
||||
const projectId = router.query.projectId as string;
|
||||
if (projectId) {
|
||||
await utils.project.one.invalidate({ projectId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(
|
||||
duplicateType === "new-project"
|
||||
? "Project duplicated successfully"
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -144,12 +144,13 @@ export const ShowProjects = () => {
|
||||
<>
|
||||
<div className="flex max-sm:flex-col gap-4 items-center w-full">
|
||||
<div className="flex-1 relative max-sm:w-full">
|
||||
<Input
|
||||
<FocusShortcutInput
|
||||
placeholder="Filter projects..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pr-10"
|
||||
/>
|
||||
|
||||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-48 max-sm:w-full">
|
||||
@@ -290,45 +291,48 @@ export const ShowProjects = () => {
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
{/*
|
||||
{project.compose.length > 0 && (
|
||||
{project.environments.some(
|
||||
(env) => env.compose.length > 0,
|
||||
) && (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>
|
||||
Compose
|
||||
</DropdownMenuLabel>
|
||||
{project.compose.map((comp) => (
|
||||
<div key={comp.composeId}>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
|
||||
{comp.name}
|
||||
<StatusTooltip
|
||||
status={comp.composeStatus}
|
||||
/>
|
||||
</DropdownMenuLabel>
|
||||
{project.environments.map((env) =>
|
||||
env.compose.map((comp) => (
|
||||
<div key={comp.composeId}>
|
||||
<DropdownMenuSeparator />
|
||||
{comp.domains.map((domain) => (
|
||||
<DropdownMenuItem
|
||||
key={domain.domainId}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
className="space-x-4 text-xs cursor-pointer justify-between"
|
||||
target="_blank"
|
||||
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
|
||||
{comp.name}
|
||||
<StatusTooltip
|
||||
status={comp.composeStatus}
|
||||
/>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{comp.domains.map((domain) => (
|
||||
<DropdownMenuItem
|
||||
key={domain.domainId}
|
||||
asChild
|
||||
>
|
||||
<span className="truncate">
|
||||
{domain.host}
|
||||
</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</div>
|
||||
))}
|
||||
<Link
|
||||
className="space-x-4 text-xs cursor-pointer justify-between"
|
||||
target="_blank"
|
||||
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{domain.host}
|
||||
</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</div>
|
||||
)),
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
)} */}
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
@@ -96,9 +96,9 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
In order to make the database reachable through the internet, you
|
||||
must set a port and ensure that the port is not being used by
|
||||
another application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
import { BookIcon, CircuitBoard, GlobeIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
import {
|
||||
extractServices,
|
||||
type Services,
|
||||
} from "@/components/dashboard/settings/users/add-permissions";
|
||||
import {
|
||||
MariadbIcon,
|
||||
MongodbIcon,
|
||||
@@ -20,13 +24,34 @@ import {
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
// import {
|
||||
// extractServices,
|
||||
// type Services,
|
||||
// } from "@/pages/dashboard/project/[projectId]";
|
||||
import { api } from "@/utils/api";
|
||||
import { StatusTooltip } from "../shared/status-tooltip";
|
||||
|
||||
// Extended Services type to include environmentId and environmentName for search navigation
|
||||
type SearchServices = Services & {
|
||||
environmentId: string;
|
||||
environmentName: string;
|
||||
};
|
||||
|
||||
const extractAllServicesFromProject = (project: any): SearchServices[] => {
|
||||
const allServices: SearchServices[] = [];
|
||||
|
||||
// Iterate through all environments in the project
|
||||
project.environments?.forEach((environment: any) => {
|
||||
const environmentServices = extractServices(environment);
|
||||
const servicesWithEnvironmentId: SearchServices[] = environmentServices.map(
|
||||
(service) => ({
|
||||
...service,
|
||||
environmentId: environment.environmentId,
|
||||
environmentName: environment.name,
|
||||
}),
|
||||
);
|
||||
allServices.push(...servicesWithEnvironmentId);
|
||||
});
|
||||
|
||||
return allServices;
|
||||
};
|
||||
|
||||
export const SearchCommand = () => {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
@@ -51,7 +76,7 @@ export const SearchCommand = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* <CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandInput
|
||||
placeholder={"Search projects or settings"}
|
||||
value={search}
|
||||
@@ -63,25 +88,36 @@ export const SearchCommand = () => {
|
||||
</CommandEmpty>
|
||||
<CommandGroup heading={"Projects"}>
|
||||
<CommandList>
|
||||
{data?.map((project) => (
|
||||
<CommandItem
|
||||
key={project.projectId}
|
||||
onSelect={() => {
|
||||
router.push(`/dashboard/project/${project.projectId}`);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<BookIcon className="size-4 text-muted-foreground mr-2" />
|
||||
{project.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
{data?.map((project) => {
|
||||
const productionEnvironment = project.environments.find(
|
||||
(environment) => environment.name === "production",
|
||||
);
|
||||
|
||||
if (!productionEnvironment) return null;
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={project.projectId}
|
||||
onSelect={() => {
|
||||
router.push(
|
||||
`/dashboard/project/${project.projectId}/environment/${productionEnvironment!.environmentId}`,
|
||||
);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<BookIcon className="size-4 text-muted-foreground mr-2" />
|
||||
{project.name} / {productionEnvironment!.name}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandList>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading={"Services"}>
|
||||
<CommandList>
|
||||
{data?.map((project) => {
|
||||
const applications: Services[] = extractServices(project);
|
||||
const applications: SearchServices[] =
|
||||
extractAllServicesFromProject(project);
|
||||
return applications.map((application) => (
|
||||
<CommandItem
|
||||
key={application.id}
|
||||
@@ -114,7 +150,8 @@ export const SearchCommand = () => {
|
||||
<CircuitBoard className="h-6 w-6 mr-2" />
|
||||
)}
|
||||
<span className="flex-grow">
|
||||
{project.name} / {application.name}{" "}
|
||||
{project.name} / {application.environmentName} /{" "}
|
||||
{application.name}{" "}
|
||||
<div style={{ display: "none" }}>{application.id}</div>
|
||||
</span>
|
||||
<div>
|
||||
@@ -181,7 +218,7 @@ export const SearchCommand = () => {
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog> */}
|
||||
</CommandDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -65,6 +65,11 @@ export const AddCertificate = () => {
|
||||
const { mutateAsync, isError, error, isLoading } =
|
||||
api.certificates.create.useMutation();
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
const hasServers = servers && servers.length > 0;
|
||||
// Show dropdown logic based on cloud environment
|
||||
// Cloud: show only if there are remote servers (no Dokploy option)
|
||||
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
|
||||
const shouldShowServerDropdown = hasServers;
|
||||
|
||||
const form = useForm<AddCertificate>({
|
||||
defaultValues: {
|
||||
@@ -85,7 +90,7 @@ export const AddCertificate = () => {
|
||||
certificateData: data.certificateData,
|
||||
privateKey: data.privateKey,
|
||||
autoRenew: data.autoRenew,
|
||||
serverId: data.serverId,
|
||||
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
|
||||
organizationId: "",
|
||||
})
|
||||
.then(async () => {
|
||||
@@ -174,52 +179,70 @@ export const AddCertificate = () => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
|
||||
Select a Server {!isCloud && "(Optional)"}
|
||||
<HelpCircle className="size-4 text-muted-foreground" />
|
||||
</FormLabel>
|
||||
</TooltipTrigger>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{shouldShowServerDropdown && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
|
||||
Select a Server {!isCloud && "(Optional)"}
|
||||
<HelpCircle className="size-4 text-muted-foreground" />
|
||||
</FormLabel>
|
||||
</TooltipTrigger>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a Server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{servers?.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
value={server.serverId}
|
||||
>
|
||||
<span className="flex items-center gap-2 justify-between w-full">
|
||||
<span>{server.name}</span>
|
||||
<span className="text-muted-foreground text-xs self-center">
|
||||
{server.ipAddress}
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={
|
||||
field.value || (!isCloud ? "dokploy" : undefined)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{!isCloud && (
|
||||
<SelectItem value="dokploy">
|
||||
<span className="flex items-center gap-2 justify-between w-full">
|
||||
<span>Dokploy</span>
|
||||
<span className="text-muted-foreground text-xs self-center">
|
||||
Default
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>Servers ({servers?.length})</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SelectItem>
|
||||
)}
|
||||
{servers?.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
value={server.serverId}
|
||||
>
|
||||
<span className="flex items-center gap-2 justify-between w-full">
|
||||
<span>{server.name}</span>
|
||||
<span className="text-muted-foreground text-xs self-center">
|
||||
{server.ipAddress}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<DialogFooter className="flex w-full flex-row !justify-end">
|
||||
|
||||
@@ -30,6 +30,9 @@ const Schema = z.object({
|
||||
name: z.string().min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
appName: z.string().min(1, {
|
||||
message: "App Name is required",
|
||||
}),
|
||||
});
|
||||
|
||||
type Schema = z.infer<typeof Schema>;
|
||||
@@ -55,6 +58,7 @@ export const EditGithubProvider = ({ githubId }: Props) => {
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
appName: "",
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
@@ -62,6 +66,7 @@ export const EditGithubProvider = ({ githubId }: Props) => {
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
name: github?.gitProvider.name || "",
|
||||
appName: github?.githubAppName || "",
|
||||
});
|
||||
}, [form, isOpen]);
|
||||
|
||||
@@ -70,6 +75,7 @@ export const EditGithubProvider = ({ githubId }: Props) => {
|
||||
githubId,
|
||||
name: data.name || "",
|
||||
gitProviderId: github?.gitProviderId || "",
|
||||
githubAppName: data.appName || "",
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.gitProvider.getAll.invalidate();
|
||||
@@ -124,6 +130,22 @@ export const EditGithubProvider = ({ githubId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="appName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>App Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="pp Name eg(my-personal)"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex w-full justify-between gap-4 mt-4">
|
||||
<Button
|
||||
|
||||
@@ -101,6 +101,15 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
||||
decoration: z.boolean().default(true),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("ntfy"),
|
||||
serverUrl: z.string().min(1, { message: "Server URL is required" }),
|
||||
topic: z.string().min(1, { message: "Topic is required" }),
|
||||
accessToken: z.string().min(1, { message: "Access Token is required" }),
|
||||
priority: z.number().min(1).max(5).default(3),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
]);
|
||||
|
||||
export const notificationsMap = {
|
||||
@@ -124,6 +133,10 @@ export const notificationsMap = {
|
||||
icon: <MessageCircleMore size={29} className="text-muted-foreground" />,
|
||||
label: "Gotify",
|
||||
},
|
||||
ntfy: {
|
||||
icon: <MessageCircleMore size={29} className="text-muted-foreground" />,
|
||||
label: "ntfy",
|
||||
},
|
||||
};
|
||||
|
||||
export type NotificationSchema = z.infer<typeof notificationSchema>;
|
||||
@@ -155,6 +168,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
api.notification.testEmailConnection.useMutation();
|
||||
const { mutateAsync: testGotifyConnection, isLoading: isLoadingGotify } =
|
||||
api.notification.testGotifyConnection.useMutation();
|
||||
const { mutateAsync: testNtfyConnection, isLoading: isLoadingNtfy } =
|
||||
api.notification.testNtfyConnection.useMutation();
|
||||
const slackMutation = notificationId
|
||||
? api.notification.updateSlack.useMutation()
|
||||
: api.notification.createSlack.useMutation();
|
||||
@@ -170,6 +185,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
const gotifyMutation = notificationId
|
||||
? api.notification.updateGotify.useMutation()
|
||||
: api.notification.createGotify.useMutation();
|
||||
const ntfyMutation = notificationId
|
||||
? api.notification.updateNtfy.useMutation()
|
||||
: api.notification.createNtfy.useMutation();
|
||||
|
||||
const form = useForm<NotificationSchema>({
|
||||
defaultValues: {
|
||||
@@ -266,6 +284,20 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
name: notification.name,
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
});
|
||||
} else if (notification.notificationType === "ntfy") {
|
||||
form.reset({
|
||||
appBuildError: notification.appBuildError,
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
type: notification.notificationType,
|
||||
accessToken: notification.ntfy?.accessToken,
|
||||
topic: notification.ntfy?.topic,
|
||||
priority: notification.ntfy?.priority,
|
||||
serverUrl: notification.ntfy?.serverUrl,
|
||||
name: notification.name,
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
form.reset();
|
||||
@@ -278,6 +310,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
discord: discordMutation,
|
||||
email: emailMutation,
|
||||
gotify: gotifyMutation,
|
||||
ntfy: ntfyMutation,
|
||||
};
|
||||
|
||||
const onSubmit = async (data: NotificationSchema) => {
|
||||
@@ -366,6 +399,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
notificationId: notificationId || "",
|
||||
gotifyId: notification?.gotifyId || "",
|
||||
});
|
||||
} else if (data.type === "ntfy") {
|
||||
promise = ntfyMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
serverUrl: data.serverUrl,
|
||||
accessToken: data.accessToken,
|
||||
topic: data.topic,
|
||||
priority: data.priority,
|
||||
name: data.name,
|
||||
dockerCleanup: dockerCleanup,
|
||||
notificationId: notificationId || "",
|
||||
ntfyId: notification?.ntfyId || "",
|
||||
});
|
||||
}
|
||||
|
||||
if (promise) {
|
||||
@@ -875,6 +923,83 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "ntfy" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://ntfy.sh" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="topic"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Topic</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="deployments" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="accessToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Access Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="AzxcvbnmKjhgfdsa..."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="priority"
|
||||
defaultValue={3}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Priority</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="3"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value) {
|
||||
const port = Number.parseInt(value);
|
||||
if (port > 0 && port <= 5) {
|
||||
field.onChange(port);
|
||||
}
|
||||
}
|
||||
}}
|
||||
type="number"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Message priority (1-5, default: 3)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -1024,7 +1149,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
isLoadingTelegram ||
|
||||
isLoadingDiscord ||
|
||||
isLoadingEmail ||
|
||||
isLoadingGotify
|
||||
isLoadingGotify ||
|
||||
isLoadingNtfy
|
||||
}
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
@@ -1061,6 +1187,13 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
priority: form.getValues("priority"),
|
||||
decoration: form.getValues("decoration"),
|
||||
});
|
||||
} else if (type === "ntfy") {
|
||||
await testNtfyConnection({
|
||||
serverUrl: form.getValues("serverUrl"),
|
||||
topic: form.getValues("topic"),
|
||||
accessToken: form.getValues("accessToken"),
|
||||
priority: form.getValues("priority"),
|
||||
});
|
||||
}
|
||||
toast.success("Connection Success");
|
||||
} catch {
|
||||
|
||||
@@ -88,6 +88,11 @@ export const ShowNotifications = () => {
|
||||
<MessageCircleMore className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{notification.notificationType === "ntfy" && (
|
||||
<div className="flex items-center justify-center rounded-lg ">
|
||||
<MessageCircleMore className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notification.name}
|
||||
</span>
|
||||
|
||||
@@ -33,7 +33,10 @@ import { Disable2FA } from "./disable-2fa";
|
||||
import { Enable2FA } from "./enable-2fa";
|
||||
|
||||
const profileSchema = z.object({
|
||||
email: z.string(),
|
||||
email: z
|
||||
.string()
|
||||
.email("Please enter a valid email address")
|
||||
.min(1, "Email is required"),
|
||||
password: z.string().nullable(),
|
||||
currentPassword: z.string().nullable(),
|
||||
image: z.string().optional(),
|
||||
|
||||
@@ -97,11 +97,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
);
|
||||
refetchDashboard();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
`${haveTraefikDashboardPortEnabled ? "Disabled" : "Enabled"} Dashboard`,
|
||||
);
|
||||
});
|
||||
.catch(() => {});
|
||||
}}
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
>
|
||||
|
||||
36
apps/dokploy/components/shared/focus-shortcut-input.tsx
Normal file
36
apps/dokploy/components/shared/focus-shortcut-input.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
type Props = React.ComponentPropsWithoutRef<typeof Input>;
|
||||
|
||||
export const FocusShortcutInput = (props: Props) => {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const isMod = e.metaKey || e.ctrlKey;
|
||||
if (!isMod || e.key.toLowerCase() !== "k") return;
|
||||
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target) {
|
||||
const tag = target.tagName;
|
||||
if (
|
||||
target.isContentEditable ||
|
||||
tag === "INPUT" ||
|
||||
tag === "TEXTAREA" ||
|
||||
tag === "SELECT" ||
|
||||
target.getAttribute("role") === "textbox"
|
||||
)
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
return <Input {...props} ref={inputRef} />;
|
||||
};
|
||||
Reference in New Issue
Block a user