refactor: update project structure to use environmentId instead of projectId across components and API routes; implement environment management features

This commit is contained in:
Mauricio Siu
2025-09-01 19:48:20 -06:00
parent 6fc325fe95
commit 72f8a28f4f
31 changed files with 8527 additions and 1401 deletions
@@ -1,10 +1,10 @@
import { TemplateGenerator } from "@/components/dashboard/project/ai/template-generator";
interface Props {
projectId: string;
environmentId: string;
projectName?: string;
}
export const AddAiAssistant = ({ projectId }: Props) => {
return <TemplateGenerator projectId={projectId} />;
export const AddAiAssistant = ({ environmentId }: Props) => {
return <TemplateGenerator environmentId={environmentId} />;
};
@@ -64,11 +64,11 @@ const AddTemplateSchema = z.object({
type AddTemplate = z.infer<typeof AddTemplateSchema>;
interface Props {
projectId: string;
environmentId: string;
projectName?: string;
}
export const AddApplication = ({ projectId, projectName }: Props) => {
export const AddApplication = ({ environmentId, projectName }: Props) => {
const utils = api.useUtils();
const { data: isCloud } = api.settings.isCloud.useQuery();
const [visible, setVisible] = useState(false);
@@ -94,15 +94,15 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
name: data.name,
appName: data.appName,
description: data.description,
projectId,
environmentId,
serverId: data.serverId,
})
.then(async () => {
toast.success("Service Created");
form.reset();
setVisible(false);
await utils.project.one.invalidate({
projectId,
await utils.environment.one.invalidate({
environmentId,
});
})
.catch(() => {
@@ -65,11 +65,11 @@ const AddComposeSchema = z.object({
type AddCompose = z.infer<typeof AddComposeSchema>;
interface Props {
projectId: string;
environmentId: string;
projectName?: string;
}
export const AddCompose = ({ projectId, projectName }: Props) => {
export const AddCompose = ({ environmentId, projectName }: Props) => {
const utils = api.useUtils();
const [visible, setVisible] = useState(false);
const slug = slugify(projectName);
@@ -78,6 +78,9 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
const { mutateAsync, isLoading, error, isError } =
api.compose.create.useMutation();
// Get environment data to extract projectId
const { data: environment } = api.environment.one.useQuery({ environmentId });
const hasServers = servers && servers.length > 0;
const form = useForm<AddCompose>({
@@ -98,7 +101,7 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
await mutateAsync({
name: data.name,
description: data.description,
projectId,
environmentId,
composeType: data.composeType,
appName: data.appName,
serverId: data.serverId,
@@ -106,8 +109,9 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
.then(async () => {
toast.success("Compose Created");
setVisible(false);
await utils.project.one.invalidate({
projectId,
// Invalidate the project query to refresh the environment data
await utils.environment.one.invalidate({
environmentId,
});
})
.catch(() => {
@@ -170,11 +170,11 @@ const databasesMap = {
type AddDatabase = z.infer<typeof mySchema>;
interface Props {
projectId: string;
environmentId: string;
projectName?: string;
}
export const AddDatabase = ({ projectId, projectName }: Props) => {
export const AddDatabase = ({ environmentId, projectName }: Props) => {
const utils = api.useUtils();
const [visible, setVisible] = useState(false);
const slug = slugify(projectName);
@@ -185,6 +185,9 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
const mariadbMutation = api.mariadb.create.useMutation();
const mysqlMutation = api.mysql.create.useMutation();
// Get environment data to extract projectId
const { data: environment } = api.environment.one.useQuery({ environmentId });
const hasServers = servers && servers.length > 0;
const form = useForm<AddDatabase>({
@@ -219,7 +222,8 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
name: data.name,
appName: data.appName,
dockerImage: defaultDockerImage,
projectId,
projectId: environment?.projectId || "",
environmentId,
serverId: data.serverId,
description: data.description,
};
@@ -248,7 +252,6 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
...commonParams,
databasePassword: data.databasePassword,
serverId: data.serverId,
projectId,
});
} else if (data.type === "mariadb") {
promise = mariadbMutation.mutateAsync({
@@ -287,8 +290,9 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
databaseUser: "",
});
setVisible(false);
await utils.project.one.invalidate({
projectId,
// Invalidate the project query to refresh the environment data
await utils.environment.one.invalidate({
environmentId,
});
})
.catch(() => {
@@ -73,11 +73,11 @@ import { api } from "@/utils/api";
const TEMPLATE_BASE_URL_KEY = "dokploy_template_base_url";
interface Props {
projectId: string;
environmentId: string;
baseUrl?: string;
}
export const AddTemplate = ({ projectId, baseUrl }: Props) => {
export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed");
@@ -91,6 +91,9 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
return undefined;
});
// Get environment data to extract projectId
const { data: environment } = api.environment.one.useQuery({ environmentId });
// Save to localStorage when customBaseUrl changes
useEffect(() => {
if (customBaseUrl) {
@@ -490,7 +493,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
disabled={isLoading}
onClick={async () => {
const promise = mutateAsync({
projectId,
environmentId,
serverId: serverId || undefined,
id: template.id,
baseUrl: customBaseUrl,
@@ -498,8 +501,9 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
toast.promise(promise, {
loading: "Setting up...",
success: () => {
utils.project.one.invalidate({
projectId,
// Invalidate the project query to refresh the environment data
utils.environment.one.invalidate({
environmentId,
});
setOpen(false);
return `${template.name} template created successfully`;
@@ -0,0 +1,382 @@
import { useState } from "react";
import { useRouter } from "next/router";
import { api } from "@/utils/api";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import { ChevronDownIcon, PlusIcon, PencilIcon, TrashIcon } from "lucide-react";
interface Environment {
environmentId: string;
name: string;
description?: string | null;
createdAt: string;
}
interface AdvancedEnvironmentSelectorProps {
projectId: string;
environments: Environment[];
currentEnvironmentId?: string;
}
export const AdvancedEnvironmentSelector = ({
projectId,
environments,
currentEnvironmentId,
}: AdvancedEnvironmentSelectorProps) => {
const router = useRouter();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedEnvironment, setSelectedEnvironment] = useState<Environment | null>(null);
// Form states
const [name, setName] = useState("");
const [description, setDescription] = useState("");
// API mutations
const createEnvironment = api.environment.create.useMutation();
const updateEnvironment = api.environment.update.useMutation();
const deleteEnvironment = api.environment.remove.useMutation();
const duplicateEnvironment = api.environment.duplicate.useMutation();
// Refetch project data
const utils = api.useUtils();
const handleCreateEnvironment = async () => {
try {
await createEnvironment.mutateAsync({
projectId,
name: name.trim(),
description: description.trim() || null,
});
toast.success("Environment created successfully");
utils.project.one.invalidate({ projectId });
setIsCreateDialogOpen(false);
setName("");
setDescription("");
} catch (error) {
toast.error("Failed to create environment");
}
};
const handleUpdateEnvironment = async () => {
if (!selectedEnvironment) return;
try {
await updateEnvironment.mutateAsync({
environmentId: selectedEnvironment.environmentId,
name: name.trim(),
description: description.trim() || null,
});
toast.success("Environment updated successfully");
utils.project.one.invalidate({ projectId });
setIsEditDialogOpen(false);
setSelectedEnvironment(null);
setName("");
setDescription("");
} catch (error) {
toast.error("Failed to update environment");
}
};
const handleDeleteEnvironment = async () => {
if (!selectedEnvironment) return;
try {
await deleteEnvironment.mutateAsync({
environmentId: selectedEnvironment.environmentId,
});
toast.success("Environment deleted successfully");
utils.project.one.invalidate({ projectId });
setIsDeleteDialogOpen(false);
setSelectedEnvironment(null);
// Redirect to production if we deleted the current environment
if (selectedEnvironment.environmentId === currentEnvironmentId) {
const productionEnv = environments.find(env => env.name === "production");
if (productionEnv) {
router.push(`/dashboard/project/${projectId}/environment/${productionEnv.environmentId}`);
}
}
} catch (error) {
toast.error("Failed to delete environment");
}
};
const handleDuplicateEnvironment = async (environment: Environment) => {
try {
const result = await duplicateEnvironment.mutateAsync({
environmentId: environment.environmentId,
name: `${environment.name}-copy`,
description: environment.description,
});
toast.success("Environment duplicated successfully");
utils.project.one.invalidate({ projectId });
// Navigate to the new duplicated environment
router.push(`/dashboard/project/${projectId}/environment/${result.environmentId}`);
} catch (error) {
toast.error("Failed to duplicate environment");
}
};
const openEditDialog = (environment: Environment) => {
setSelectedEnvironment(environment);
setName(environment.name);
setDescription(environment.description || "");
setIsEditDialogOpen(true);
};
const openDeleteDialog = (environment: Environment) => {
setSelectedEnvironment(environment);
setIsDeleteDialogOpen(true);
};
const currentEnv = environments.find(env => env.environmentId === currentEnvironmentId);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="min-w-[200px] justify-between">
<div className="flex items-center gap-2">
<span>{currentEnv?.name || "Select Environment"}</span>
{currentEnv?.name === "production" && (
<span className="px-1.5 py-0.5 text-xs bg-green-100 text-green-800 rounded">
Prod
</span>
)}
</div>
<ChevronDownIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[300px]" align="start">
<DropdownMenuLabel>Environments</DropdownMenuLabel>
<DropdownMenuSeparator />
{environments.map((environment) => (
<div key={environment.environmentId} className="flex items-center">
<DropdownMenuItem
className="flex-1 cursor-pointer"
onClick={() => {
router.push(`/dashboard/project/${projectId}/environment/${environment.environmentId}`);
}}
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<span>{environment.name}</span>
{environment.name === "production" && (
<span className="px-1.5 py-0.5 text-xs bg-green-100 text-green-800 rounded">
Prod
</span>
)}
</div>
{environment.environmentId === currentEnvironmentId && (
<div className="w-2 h-2 bg-blue-500 rounded-full" />
)}
</div>
</DropdownMenuItem>
{/* Action buttons for non-production environments */}
{environment.name !== "production" && (
<div className="flex items-center gap-1 px-2">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
openEditDialog(environment);
}}
>
<PencilIcon className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
onClick={(e) => {
e.stopPropagation();
openDeleteDialog(environment);
}}
>
<TrashIcon className="h-3 w-3" />
</Button>
</div>
)}
</div>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
onClick={() => setIsCreateDialogOpen(true)}
>
<PlusIcon className="h-4 w-4 mr-2" />
Create Environment
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Create Environment Dialog */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Environment</DialogTitle>
<DialogDescription>
Create a new environment for your project.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Environment name"
/>
</div>
<div>
<Label htmlFor="description">Description (optional)</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Environment description"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsCreateDialogOpen(false);
setName("");
setDescription("");
}}
>
Cancel
</Button>
<Button
onClick={handleCreateEnvironment}
disabled={!name.trim() || createEnvironment.isPending}
>
{createEnvironment.isPending ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Environment Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Environment</DialogTitle>
<DialogDescription>
Update the environment details.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="edit-name">Name</Label>
<Input
id="edit-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Environment name"
/>
</div>
<div>
<Label htmlFor="edit-description">Description (optional)</Label>
<Textarea
id="edit-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Environment description"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsEditDialogOpen(false);
setSelectedEnvironment(null);
setName("");
setDescription("");
}}
>
Cancel
</Button>
<Button
onClick={handleUpdateEnvironment}
disabled={!name.trim() || updateEnvironment.isPending}
>
{updateEnvironment.isPending ? "Updating..." : "Update"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Environment Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Environment</DialogTitle>
<DialogDescription>
Are you sure you want to delete the environment "{selectedEnvironment?.name}"?
This action cannot be undone and will also delete all services in this environment.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsDeleteDialogOpen(false);
setSelectedEnvironment(null);
}}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteEnvironment}
disabled={deleteEnvironment.isPending}
>
{deleteEnvironment.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -90,11 +90,11 @@ export const { useStepper, steps, Scoped } = defineStepper(
);
interface Props {
projectId: string;
environmentId: string;
projectName?: string;
}
export const TemplateGenerator = ({ projectId }: Props) => {
export const TemplateGenerator = ({ environmentId }: Props) => {
const [open, setOpen] = useState(false);
const stepper = useStepper();
const { data: aiSettings } = api.ai.getAll.useQuery();
@@ -103,6 +103,9 @@ export const TemplateGenerator = ({ projectId }: Props) => {
useState<TemplateInfo>(defaultTemplateInfo);
const utils = api.useUtils();
// Get environment data to extract projectId
const { data: environment } = api.environment.one.useQuery({ environmentId });
const haveAtleasOneProviderEnabled = aiSettings?.some(
(ai) => ai.isEnabled === true,
);
@@ -121,7 +124,7 @@ export const TemplateGenerator = ({ projectId }: Props) => {
const onSubmit = async () => {
await mutateAsync({
projectId,
projectId: environment?.projectId || "",
id: templateInfo.details?.id || "",
name: templateInfo?.details?.name || "",
description: templateInfo?.details?.shortDescription || "",
@@ -138,9 +141,8 @@ export const TemplateGenerator = ({ projectId }: Props) => {
.then(async () => {
toast.success("Compose Created");
setOpen(false);
await utils.project.one.invalidate({
projectId,
});
// Invalidate the project query to refresh the environment data
await utils.project.one.invalidate();
})
.catch(() => {
toast.error("Error creating the compose");
@@ -96,22 +96,8 @@ export const ShowProjects = () => {
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
break;
case "services": {
const aTotalServices =
a.mariadb.length +
a.mongo.length +
a.mysql.length +
a.postgres.length +
a.redis.length +
a.applications.length +
a.compose.length;
const bTotalServices =
b.mariadb.length +
b.mongo.length +
b.mysql.length +
b.postgres.length +
b.redis.length +
b.applications.length +
b.compose.length;
const aTotalServices = a.environments.length;
const bTotalServices = b.environments.length;
comparison = aTotalServices - bTotalServices;
break;
}
@@ -201,23 +187,23 @@ export const ShowProjects = () => {
)}
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 flex-wrap gap-5">
{filteredProjects?.map((project) => {
const emptyServices =
project?.mariadb.length === 0 &&
project?.mongo.length === 0 &&
project?.mysql.length === 0 &&
project?.postgres.length === 0 &&
project?.redis.length === 0 &&
project?.applications.length === 0 &&
project?.compose.length === 0;
// const emptyServices =
// project?.environments.length === 0 &&
// project?.mongo.length === 0 &&
// project?.environments.mysql.length === 0 &&
// project?.environments.postgres.length === 0 &&
// project?.environments.redis.length === 0 &&
// project?.applications.length === 0 &&
// project?.compose.length === 0;
const totalServices =
project?.mariadb.length +
project?.mongo.length +
project?.mysql.length +
project?.postgres.length +
project?.redis.length +
project?.applications.length +
project?.compose.length;
// const totalServices =
// project?.mariadb.length +
// project?.mongo.length +
// project?.mysql.length +
// project?.postgres.length +
// project?.redis.length +
// project?.applications.length +
// project?.compose.length;
return (
<div
@@ -228,7 +214,7 @@ export const ShowProjects = () => {
href={`/dashboard/project/${project.projectId}`}
>
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
{project.applications.length > 0 ||
{/* {project.applications.length > 0 ||
project.compose.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -322,7 +308,7 @@ export const ShowProjects = () => {
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
) : null} */}
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2">
<span className="flex flex-col gap-1.5">
@@ -393,7 +379,7 @@ export const ShowProjects = () => {
Are you sure to delete this
project?
</AlertDialogTitle>
{!emptyServices ? (
{/* {!emptyServices ? (
<div className="flex flex-row gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950">
<AlertTriangle className="text-yellow-600 dark:text-yellow-400" />
<span className="text-sm text-yellow-600 dark:text-yellow-400">
@@ -407,14 +393,14 @@ export const ShowProjects = () => {
This action cannot be
undone
</AlertDialogDescription>
)}
)} */}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={!emptyServices}
// disabled={!emptyServices}
onClick={async () => {
await mutateAsync({
projectId:
@@ -452,12 +438,12 @@ export const ShowProjects = () => {
<DateTooltip date={project.createdAt}>
Created
</DateTooltip>
<span>
{/* <span>
{totalServices}{" "}
{totalServices === 1
? "service"
: "services"}
</span>
</span> */}
</div>
</CardFooter>
</Card>
@@ -20,10 +20,10 @@ import {
CommandSeparator,
} from "@/components/ui/command";
import { authClient } from "@/lib/auth-client";
import {
extractServices,
type Services,
} from "@/pages/dashboard/project/[projectId]";
// import {
// extractServices,
// type Services,
// } from "@/pages/dashboard/project/[projectId]";
import { api } from "@/utils/api";
import { StatusTooltip } from "../shared/status-tooltip";
@@ -51,7 +51,7 @@ export const SearchCommand = () => {
return (
<div>
<CommandDialog open={open} onOpenChange={setOpen}>
{/* <CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput
placeholder={"Search projects or settings"}
value={search}
@@ -181,7 +181,7 @@ export const SearchCommand = () => {
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
</CommandDialog> */}
</div>
);
};