Merge branch 'canary' into Issue-1852-Harikrishnan

This commit is contained in:
Mauricio Siu
2025-09-05 03:12:01 -06:00
113 changed files with 24809 additions and 2175 deletions
@@ -69,7 +69,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
toast.success("Application deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.projectId}/services/application/${applicationId}?tab=deployments`,
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
);
})
.catch(() => {
@@ -101,7 +101,9 @@ export const DeleteService = ({ id, type }: Props) => {
deleteVolumes,
})
.then((result) => {
push(`/dashboard/project/${result?.projectId}`);
push(
`/dashboard/project/${result?.environment?.projectId}/environment/${result?.environment?.environmentId}`,
);
toast.success("deleted successfully");
setIsOpen(false);
})
@@ -47,7 +47,7 @@ export const ComposeActions = ({ composeId }: Props) => {
toast.success("Compose deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.project.projectId}/services/compose/${composeId}?tab=deployments`,
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
@@ -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);
@@ -98,15 +98,15 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
name: data.name,
appName: data.appName,
description: data.description,
projectId,
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
environmentId,
})
.then(async () => {
toast.success("Service Created");
form.reset();
setVisible(false);
await utils.project.one.invalidate({
projectId,
await utils.environment.one.invalidate({
environmentId,
});
})
.catch(() => {
@@ -190,10 +190,14 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
<Select
onValueChange={field.onChange}
defaultValue={field.value || (!isCloud ? "dokploy" : undefined)}
defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
}
>
<SelectTrigger>
<SelectValue placeholder={!isCloud ? "Dokploy" : "Select a Server"} />
<SelectValue
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
@@ -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;
// Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)
@@ -102,7 +105,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 === "dokploy" ? undefined : data.serverId,
@@ -110,8 +113,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(() => {
@@ -85,7 +85,6 @@ const baseDatabaseSchema = z.object({
}),
databasePassword: z
.string()
.min(1, "Password is required")
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
@@ -123,7 +122,8 @@ const mySchema = z.discriminatedUnion("type", [
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
}),
})
.optional(),
databaseUser: z.string().default("mysql"),
databaseName: z.string().default("mysql"),
})
@@ -137,7 +137,8 @@ const mySchema = z.discriminatedUnion("type", [
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
}),
})
.optional(),
databaseUser: z.string().default("mariadb"),
databaseName: z.string().default("mariadb"),
})
@@ -170,11 +171,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);
@@ -186,6 +187,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;
// Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)
@@ -224,8 +228,8 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
name: data.name,
appName: data.appName,
dockerImage: defaultDockerImage,
projectId,
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
environmentId,
description: data.description,
};
@@ -253,13 +257,12 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
...commonParams,
databasePassword: data.databasePassword,
serverId: data.serverId === "dokploy" ? null : data.serverId,
projectId,
});
} else if (data.type === "mariadb") {
promise = mariadbMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
databaseRootPassword: data.databaseRootPassword,
databaseRootPassword: data.databaseRootPassword || "",
databaseName: data.databaseName || "mariadb",
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
@@ -272,8 +275,8 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
databaseName: data.databaseName || "mysql",
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
databaseRootPassword: data.databaseRootPassword,
serverId: data.serverId === "dokploy" ? null : data.serverId,
databaseRootPassword: data.databaseRootPassword || "",
});
}
@@ -292,8 +295,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(() => {
@@ -412,10 +416,16 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
<FormLabel>Select a Server</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || (!isCloud ? "dokploy" : undefined)}
defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
}
>
<SelectTrigger>
<SelectValue placeholder={!isCloud ? "Dokploy" : "Select a Server"} />
<SelectValue
placeholder={
!isCloud ? "Dokploy" : "Select a Server"
}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
@@ -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) {
@@ -460,10 +463,16 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
onValueChange={(e) => {
setServerId(e);
}}
defaultValue={!isCloud ? "dokploy" : undefined}
defaultValue={
!isCloud ? "dokploy" : undefined
}
>
<SelectTrigger>
<SelectValue placeholder={!isCloud ? "Dokploy" : "Select a Server"} />
<SelectValue
placeholder={
!isCloud ? "Dokploy" : "Select a Server"
}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
@@ -491,7 +500,8 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
</SelectItem>
))}
<SelectLabel>
Servers ({servers?.length + (!isCloud ? 1 : 0)})
Servers (
{servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>
@@ -505,19 +515,20 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
disabled={isLoading}
onClick={async () => {
const promise = mutateAsync({
projectId,
serverId:
serverId === "dokploy"
? undefined
: serverId,
environmentId,
id: template.id,
baseUrl: customBaseUrl,
});
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,446 @@
import type { findEnvironmentsByProjectId } from "@dokploy/server";
import {
ChevronDownIcon,
PencilIcon,
PlusIcon,
Terminal,
TrashIcon,
} from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { toast } from "sonner";
import { EnvironmentVariables } from "@/components/dashboard/project/environment-variables";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
type Environment = Awaited<
ReturnType<typeof findEnvironmentsByProjectId>
>[number];
interface AdvancedEnvironmentSelectorProps {
projectId: string;
currentEnvironmentId?: string;
}
export const AdvancedEnvironmentSelector = ({
projectId,
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);
const { data: environments } = api.environment.byProjectId.useQuery(
{ projectId: projectId },
{
enabled: !!projectId,
},
);
// Form states
const [name, setName] = useState("");
const [description, setDescription] = useState("");
// API mutations
const { data: environment } = api.environment.one.useQuery(
{ environmentId: currentEnvironmentId || "" },
{
enabled: !!currentEnvironmentId,
},
);
const haveServices =
selectedEnvironment &&
((selectedEnvironment?.mariadb?.length || 0) > 0 ||
(selectedEnvironment?.mongo?.length || 0) > 0 ||
(selectedEnvironment?.mysql?.length || 0) > 0 ||
(selectedEnvironment?.postgres?.length || 0) > 0 ||
(selectedEnvironment?.redis?.length || 0) > 0 ||
(selectedEnvironment?.applications?.length || 0) > 0 ||
(selectedEnvironment?.compose?.length || 0) > 0);
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.environment.byProjectId.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.environment.byProjectId.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.environment.byProjectId.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="ghost" className="h-auto p-2 font-normal">
<div className="flex items-center gap-1">
<span className="text-muted-foreground">/</span>
<span>{currentEnv?.name || "Select Environment"}</span>
<ChevronDownIcon className="h-4 w-4 text-muted-foreground" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[300px]" align="start">
<DropdownMenuLabel>Environments</DropdownMenuLabel>
<DropdownMenuSeparator />
{environments?.map((environment) => {
const servicesCount =
environment.mariadb.length +
environment.mongo.length +
environment.mysql.length +
environment.postgres.length +
environment.redis.length +
environment.applications.length +
environment.compose.length;
return (
<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">
<span>
{environment.name} ({servicesCount})
</span>
{environment.environmentId === currentEnvironmentId && (
<div className="w-2 h-2 bg-blue-500 rounded-full" />
)}
</div>
</DropdownMenuItem>
{/* Action buttons for non-production environments */}
<EnvironmentVariables environmentId={environment.environmentId}>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
}}
>
<Terminal className="h-3 w-3" />
</Button>
</EnvironmentVariables>
{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>
<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 className="space-y-1">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Environment name"
/>
</div>
<div className="space-y-1">
<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.isLoading}
>
{createEnvironment.isLoading ? "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 className="space-y-1">
<Label htmlFor="edit-name">Name</Label>
<Input
id="edit-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Environment name"
/>
</div>
<div className="space-y-1">
<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.isLoading}
>
{updateEnvironment.isLoading ? "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>
{haveServices && (
<AlertBlock type="warning">
This environment have active services, please delete them first.
</AlertBlock>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsDeleteDialogOpen(false);
setSelectedEnvironment(null);
}}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteEnvironment}
disabled={
deleteEnvironment.isLoading ||
haveServices ||
!selectedEnvironment
}
>
{deleteEnvironment.isLoading ? "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();
@@ -121,7 +121,7 @@ export const TemplateGenerator = ({ projectId }: Props) => {
const onSubmit = async () => {
await mutateAsync({
projectId,
environmentId: environmentId,
id: templateInfo.details?.id || "",
name: templateInfo?.details?.name || "",
description: templateInfo?.details?.shortDescription || "",
@@ -138,8 +138,9 @@ 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.environment.one.invalidate({
environmentId,
});
})
.catch(() => {
@@ -15,6 +15,13 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
export type Services = {
@@ -36,23 +43,35 @@ export type Services = {
};
interface DuplicateProjectProps {
projectId: string;
environmentId: string;
services: Services[];
selectedServiceIds: string[];
}
export const DuplicateProject = ({
projectId,
environmentId,
services,
selectedServiceIds,
}: DuplicateProjectProps) => {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [duplicateType, setDuplicateType] = useState("new-project"); // "new-project" or "same-project"
const [duplicateType, setDuplicateType] = useState("new-project"); // "new-project" or "existing-environment"
const [selectedTargetProject, setSelectedTargetProject] =
useState<string>("");
const [selectedTargetEnvironment, setSelectedTargetEnvironment] =
useState<string>("");
const utils = api.useUtils();
const router = useRouter();
// Queries for project and environment selection
const { data: allProjects } = api.project.all.useQuery();
const { data: selectedProjectEnvironments } =
api.environment.byProjectId.useQuery(
{ projectId: selectedTargetProject },
{ enabled: !!selectedTargetProject },
);
const selectedServices = services.filter((service) =>
selectedServiceIds.includes(service.id),
);
@@ -68,7 +87,9 @@ export const DuplicateProject = ({
);
setOpen(false);
if (duplicateType === "new-project") {
router.push(`/dashboard/project/${newProject.projectId}`);
router.push(
`/dashboard/project/${newProject?.projectId}/environment/${newProject?.environmentId}`,
);
}
},
onError: (error) => {
@@ -82,8 +103,20 @@ export const DuplicateProject = ({
return;
}
if (duplicateType === "existing-environment") {
if (!selectedTargetProject) {
toast.error("Please select a target project");
return;
}
if (!selectedTargetEnvironment) {
toast.error("Please select a target environment");
return;
}
}
// TODO: Update duplicate API to support targetProjectId and targetEnvironmentId
await duplicateProject({
sourceProjectId: projectId,
sourceEnvironmentId: selectedTargetEnvironment,
name,
description,
includeServices: true,
@@ -91,7 +124,7 @@ export const DuplicateProject = ({
id: service.id,
type: service.type,
})),
duplicateInSameProject: duplicateType === "same-project",
duplicateInSameProject: duplicateType === "existing-environment",
});
};
@@ -105,6 +138,8 @@ export const DuplicateProject = ({
setName("");
setDescription("");
setDuplicateType("new-project");
setSelectedTargetProject("");
setSelectedTargetEnvironment("");
}
}}
>
@@ -127,7 +162,14 @@ export const DuplicateProject = ({
<Label>Duplicate to</Label>
<RadioGroup
value={duplicateType}
onValueChange={setDuplicateType}
onValueChange={(value) => {
setDuplicateType(value);
// Reset selections when changing type
if (value !== "existing-environment") {
setSelectedTargetProject("");
setSelectedTargetEnvironment("");
}
}}
className="grid gap-2"
>
<div className="flex items-center space-x-2">
@@ -135,8 +177,13 @@ export const DuplicateProject = ({
<Label htmlFor="new-project">New project</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="same-project" id="same-project" />
<Label htmlFor="same-project">Same project</Label>
<RadioGroupItem
value="existing-environment"
id="existing-environment"
/>
<Label htmlFor="existing-environment">
Existing environment
</Label>
</div>
</RadioGroup>
</div>
@@ -165,6 +212,74 @@ export const DuplicateProject = ({
</>
)}
{duplicateType === "existing-environment" && (
<>
{allProjects?.filter((p) => p.projectId !== environmentId)
.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-4 text-center">
<p className="text-sm text-muted-foreground">
No other projects available. Create a new project first.
</p>
</div>
) : (
<>
{/* Step 1: Select Project */}
<div className="grid gap-2">
<Label>Target Project</Label>
<Select
value={selectedTargetProject}
onValueChange={(value) => {
setSelectedTargetProject(value);
setSelectedTargetEnvironment(""); // Reset environment when project changes
}}
>
<SelectTrigger>
<SelectValue placeholder="Select target project" />
</SelectTrigger>
<SelectContent>
{allProjects
?.filter((p) => p.projectId !== environmentId)
.map((project) => (
<SelectItem
key={project.projectId}
value={project.projectId}
>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Step 2: Select Environment (only show if project is selected) */}
{selectedTargetProject && (
<div className="grid gap-2">
<Label>Target Environment</Label>
<Select
value={selectedTargetEnvironment}
onValueChange={setSelectedTargetEnvironment}
>
<SelectTrigger>
<SelectValue placeholder="Select target environment" />
</SelectTrigger>
<SelectContent>
{selectedProjectEnvironments?.map((env) => (
<SelectItem
key={env.environmentId}
value={env.environmentId}
>
{env.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</>
)}
</>
)}
<div className="grid gap-2">
<Label>Selected services to duplicate</Label>
<div className="space-y-2 max-h-[200px] overflow-y-auto border rounded-md p-4">
@@ -187,18 +302,26 @@ export const DuplicateProject = ({
>
Cancel
</Button>
<Button onClick={handleDuplicate} disabled={isLoading}>
<Button
onClick={handleDuplicate}
disabled={
isLoading ||
(duplicateType === "new-project" && !name) ||
(duplicateType === "existing-environment" &&
(!selectedTargetProject || !selectedTargetEnvironment))
}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{duplicateType === "new-project"
? "Duplicating project..."
: "Duplicating services..."}
? "Duplicating to new project..."
: "Duplicating to environment..."}
</>
) : duplicateType === "new-project" ? (
"Duplicate project"
"Duplicate to new project"
) : (
"Duplicate services"
"Duplicate to environment"
)}
</Button>
</DialogFooter>
@@ -0,0 +1,157 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Terminal } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { api } from "@/utils/api";
const updateEnvironmentSchema = z.object({
env: z.string().optional(),
});
type UpdateEnvironment = z.infer<typeof updateEnvironmentSchema>;
interface Props {
environmentId: string;
children?: React.ReactNode;
}
export const EnvironmentVariables = ({ environmentId, children }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, error, isError, isLoading } =
api.environment.update.useMutation();
const { data } = api.environment.one.useQuery(
{
environmentId,
},
{
enabled: !!environmentId,
},
);
const form = useForm<UpdateEnvironment>({
defaultValues: {
env: data?.env ?? "",
},
resolver: zodResolver(updateEnvironmentSchema),
});
useEffect(() => {
if (data) {
form.reset({
env: data.env ?? "",
});
}
}, [data, form, form.reset]);
const onSubmit = async (formData: UpdateEnvironment) => {
await mutateAsync({
env: formData.env || "",
environmentId: environmentId,
})
.then(() => {
toast.success("Environment variables updated successfully");
utils.environment.one.invalidate({ environmentId });
})
.catch(() => {
toast.error("Error updating the environment variables");
})
.finally(() => {});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{children ?? (
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
<Terminal className="size-4" />
<span>Environment Variables</span>
</DropdownMenuItem>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-6xl">
<DialogHeader>
<DialogTitle>Environment Variables</DialogTitle>
<DialogDescription>
Update the environment variables that are accessible to all services
in this environment.
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<AlertBlock type="info">
Use this syntax to reference environment-level variables in your
service environments:{" "}
<code>API_URL=${"{{environment.API_URL}}"}</code>
</AlertBlock>
<div className="grid gap-4">
<div className="grid items-center gap-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 "
>
<FormField
control={form.control}
name="env"
render={({ field }) => (
<FormItem>
<FormLabel>Environment variables</FormLabel>
<FormControl>
<CodeEditor
lineWrapping
language="properties"
wrapperClassName="h-[35rem] font-mono"
placeholder={`NODE_ENV=development
DATABASE_URL=postgresql://localhost:5432/mydb
API_KEY=your-api-key-here
`}
{...field}
/>
</FormControl>
<pre>
<FormMessage />
</pre>
</FormItem>
)}
/>
<DialogFooter>
<Button isLoading={isLoading} type="submit">
Update
</Button>
</DialogFooter>
</form>
</Form>
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -101,7 +101,18 @@ export const HandleProject = ({ projectId }: Props) => {
toast.success(projectId ? "Project Updated" : "Project Created");
setIsOpen(false);
if (!projectId) {
router.push(`/dashboard/project/${data?.projectId}`);
const projectIdToUse =
data && "project" in data ? data.project.projectId : undefined;
const environmentIdToUse =
data && "environment" in data
? data.environment.environmentId
: undefined;
if (environmentIdToUse && projectIdToUse) {
router.push(
`/dashboard/project/${projectIdToUse}/environment/${environmentIdToUse}`,
);
}
} else {
refetch();
}
@@ -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,40 @@ 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
.map(
(env) =>
env.applications.length === 0 &&
env.mariadb.length === 0 &&
env.mongo.length === 0 &&
env.mysql.length === 0 &&
env.postgres.length === 0 &&
env.redis.length === 0 &&
env.applications.length === 0 &&
env.compose.length === 0,
)
.every(Boolean);
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?.environments
.map(
(env) =>
env.mariadb.length +
env.mongo.length +
env.mysql.length +
env.postgres.length +
env.redis.length +
env.applications.length +
env.compose.length,
)
.reduce((acc, curr) => acc + curr, 0);
const haveServicesWithDomains = project?.environments
.map(
(env) =>
env.applications.length > 0 ||
env.compose.length > 0,
)
.some(Boolean);
return (
<div
@@ -225,11 +228,10 @@ export const ShowProjects = () => {
className="w-full lg:max-w-md"
>
<Link
href={`/dashboard/project/${project.projectId}`}
href={`/dashboard/project/${project.projectId}/environment/${project?.environments?.[0]?.environmentId}`}
>
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
{project.applications.length > 0 ||
project.compose.length > 0 ? (
{haveServicesWithDomains ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -244,44 +246,51 @@ export const ShowProjects = () => {
className="w-[200px] space-y-2 overflow-y-auto max-h-[400px]"
onClick={(e) => e.stopPropagation()}
>
{project.applications.length > 0 && (
{project.environments.some(
(env) => env.applications.length > 0,
) && (
<DropdownMenuGroup>
<DropdownMenuLabel>
Applications
</DropdownMenuLabel>
{project.applications.map((app) => (
<div key={app.applicationId}>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
{app.name}
<StatusTooltip
status={app.applicationStatus}
/>
</DropdownMenuLabel>
{project.environments.map((env) =>
env.applications.map((app) => (
<div key={app.applicationId}>
<DropdownMenuSeparator />
{app.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">
{app.name}
<StatusTooltip
status={
app.applicationStatus
}
/>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{app.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>
)}
{/*
{project.compose.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>
@@ -319,7 +328,7 @@ export const ShowProjects = () => {
</div>
))}
</DropdownMenuGroup>
)}
)} */}
</DropdownMenuContent>
</DropdownMenu>
) : null}
@@ -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}
@@ -87,7 +87,7 @@ export const SearchCommand = () => {
key={application.id}
onSelect={() => {
router.push(
`/dashboard/project/${project.projectId}/services/${application.type}/${application.id}`,
`/dashboard/project/${project.projectId}/environment/${application.environmentId}/services/${application.type}/${application.id}`,
);
setOpen(false);
}}
@@ -181,7 +181,7 @@ export const SearchCommand = () => {
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
</CommandDialog> */}
</div>
);
};
@@ -35,9 +35,9 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { extractServices } from "@/pages/dashboard/project/[projectId]";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
import { extractServices } from "../users/add-permissions";
interface Props {
serverId?: string;
@@ -95,11 +95,13 @@ export const SetupMonitoring = ({ serverId }: Props) => {
const { data: projects } = api.project.all.useQuery();
const extractServicesFromProjects = (projects: any[] | undefined) => {
const extractServicesFromProjects = () => {
if (!projects) return [];
const allServices = projects.flatMap((project) => {
const services = extractServices(project);
const services = project.environments.flatMap((env) =>
extractServices(env),
);
return serverId
? services
.filter((service) => service.serverId === serverId)
@@ -110,7 +112,7 @@ export const SetupMonitoring = ({ serverId }: Props) => {
return [...new Set(allServices)];
};
const services = extractServicesFromProjects(projects);
const services = extractServicesFromProjects();
const form = useForm<Schema>({
resolver: zodResolver(Schema),
@@ -1,3 +1,4 @@
import type { findEnvironmentById } from "@dokploy/server/index";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -26,11 +27,135 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { extractServices } from "@/pages/dashboard/project/[projectId]";
import { api } from "@/utils/api";
type Environment = Omit<
Awaited<ReturnType<typeof findEnvironmentById>>,
"project"
>;
export type Services = {
appName: string;
serverId?: string | null;
name: string;
type:
| "mariadb"
| "application"
| "postgres"
| "mysql"
| "mongo"
| "redis"
| "compose";
description?: string | null;
id: string;
createdAt: string;
status?: "idle" | "running" | "done" | "error";
};
export const extractServices = (data: Environment | undefined) => {
const applications: Services[] =
data?.applications.map((item) => ({
appName: item.appName,
name: item.name,
type: "application",
id: item.applicationId,
createdAt: item.createdAt,
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
})) || [];
const mariadb: Services[] =
data?.mariadb.map((item) => ({
appName: item.appName,
name: item.name,
type: "mariadb",
id: item.mariadbId,
createdAt: item.createdAt,
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
})) || [];
const postgres: Services[] =
data?.postgres.map((item) => ({
appName: item.appName,
name: item.name,
type: "postgres",
id: item.postgresId,
createdAt: item.createdAt,
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
})) || [];
const mongo: Services[] =
data?.mongo.map((item) => ({
appName: item.appName,
name: item.name,
type: "mongo",
id: item.mongoId,
createdAt: item.createdAt,
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
})) || [];
const redis: Services[] =
data?.redis.map((item) => ({
appName: item.appName,
name: item.name,
type: "redis",
id: item.redisId,
createdAt: item.createdAt,
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
})) || [];
const mysql: Services[] =
data?.mysql.map((item) => ({
appName: item.appName,
name: item.name,
type: "mysql",
id: item.mysqlId,
createdAt: item.createdAt,
status: item.applicationStatus,
description: item.description,
serverId: item.serverId,
})) || [];
const compose: Services[] =
data?.compose.map((item) => ({
appName: item.appName,
name: item.name,
type: "compose",
id: item.composeId,
createdAt: item.createdAt,
status: item.composeStatus,
description: item.description,
serverId: item.serverId,
})) || [];
applications.push(
...mysql,
...redis,
...mongo,
...postgres,
...mariadb,
...compose,
);
applications.sort((a, b) => {
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
return applications;
};
const addPermissions = z.object({
accessedProjects: z.array(z.string()).optional(),
accessedEnvironments: z.array(z.string()).optional(),
accessedServices: z.array(z.string()).optional(),
canCreateProjects: z.boolean().optional().default(false),
canCreateServices: z.boolean().optional().default(false),
@@ -76,6 +201,7 @@ export const AddUserPermissions = ({ userId }: Props) => {
if (data) {
form.reset({
accessedProjects: data.accessedProjects || [],
accessedEnvironments: data.accessedEnvironments || [],
accessedServices: data.accessedServices || [],
canCreateProjects: data.canCreateProjects,
canCreateServices: data.canCreateServices,
@@ -99,6 +225,7 @@ export const AddUserPermissions = ({ userId }: Props) => {
canDeleteProjects: data.canDeleteProjects,
canAccessToTraefikFiles: data.canAccessToTraefikFiles,
accessedProjects: data.accessedProjects || [],
accessedEnvironments: data.accessedEnvironments || [],
accessedServices: data.accessedServices || [],
canAccessToDocker: data.canAccessToDocker,
canAccessToAPI: data.canAccessToAPI,
@@ -332,89 +459,317 @@ export const AddUserPermissions = ({ userId }: Props) => {
No projects found
</p>
)}
<div className="grid md:grid-cols-2 gap-4">
{projects?.map((item, index) => {
const applications = extractServices(item);
<div className="grid md:grid-cols-1 gap-4">
{projects?.map((project, projectIndex) => {
return (
<FormField
key={`project-${index}`}
key={`project-${projectIndex}`}
control={form.control}
name="accessedProjects"
render={({ field }) => {
return (
<FormItem
key={item.projectId}
className="flex flex-col items-start space-x-4 rounded-lg p-4 border"
key={project.projectId}
className="flex flex-col items-start rounded-lg p-4 border"
>
<div className="flex flex-row gap-4">
{/* Project Header */}
<div className="flex flex-row gap-4 items-center w-full">
<FormControl>
<Checkbox
checked={field.value?.includes(
item.projectId,
project.projectId,
)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...(field.value || []),
item.projectId,
])
: field.onChange(
field.value?.filter(
(value) =>
value !== item.projectId,
),
if (checked) {
// Add the project
field.onChange([
...(field.value || []),
project.projectId,
]);
} else {
// Remove the project
field.onChange(
field.value?.filter(
(value) =>
value !== project.projectId,
),
);
// Also remove all environments and services from this project
const currentEnvs =
form.getValues(
"accessedEnvironments",
) || [];
const currentServices =
form.getValues(
"accessedServices",
) || [];
// Get all environment IDs from this project
const projectEnvIds =
project.environments.map(
(env) => env.environmentId,
);
// Get all service IDs from this project
const projectServiceIds =
project.environments.flatMap(
(env) =>
extractServices(env).map(
(service) => service.id,
),
);
// Remove environments and services from this project
form.setValue(
"accessedEnvironments",
currentEnvs.filter(
(envId) =>
!projectEnvIds.includes(envId),
),
);
form.setValue(
"accessedServices",
currentServices.filter(
(serviceId) =>
!projectServiceIds.includes(
serviceId,
),
),
);
}
}}
/>
</FormControl>
<FormLabel className="text-sm font-medium text-primary">
{item.name}
<FormLabel className="text-base font-semibold text-primary">
{project.name}
</FormLabel>
</div>
{applications.length === 0 && (
<p className="text-sm text-muted-foreground">
No services found
</p>
)}
{applications?.map((item, index) => (
<FormField
key={`project-${index}`}
control={form.control}
name="accessedServices"
render={({ field }) => {
{/* Environments */}
<div className="ml-6 w-full space-y-3">
{project.environments.length === 0 && (
<p className="text-sm text-muted-foreground">
No environments found
</p>
)}
{project.environments.map(
(environment, envIndex) => {
const services =
extractServices(environment);
return (
<FormItem
key={item.id}
className="flex flex-row items-start space-x-3 space-y-0"
<div
key={`env-${envIndex}`}
className="border-l-2 border-muted pl-4"
>
<FormControl>
<Checkbox
checked={field.value?.includes(
item.id,
)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...(field.value || []),
item.id,
])
: field.onChange(
field.value?.filter(
(value) =>
value !== item.id,
),
{/* Environment Header with Checkbox */}
<FormField
key={`env-${envIndex}`}
control={form.control}
name="accessedEnvironments"
render={({ field: envField }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0 mb-2">
<FormControl>
<Checkbox
checked={envField.value?.includes(
environment.environmentId,
)}
onCheckedChange={(
checked,
) => {
if (checked) {
// Add the environment
envField.onChange([
...(envField.value ||
[]),
environment.environmentId,
]);
// Auto-select the project if not already selected
const currentProjects =
form.getValues(
"accessedProjects",
) || [];
if (
!currentProjects.includes(
project.projectId,
)
) {
form.setValue(
"accessedProjects",
[
...currentProjects,
project.projectId,
],
);
}
} else {
// Remove the environment
envField.onChange(
envField.value?.filter(
(value) =>
value !==
environment.environmentId,
),
);
// Also remove all services from this environment
const currentServices =
form.getValues(
"accessedServices",
) || [];
const environmentServiceIds =
services.map(
(service) =>
service.id,
);
form.setValue(
"accessedServices",
currentServices.filter(
(serviceId) =>
!environmentServiceIds.includes(
serviceId,
),
),
);
}
}}
/>
</FormControl>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-500 rounded-full" />
<FormLabel className="text-sm font-medium text-foreground cursor-pointer">
{environment.name}
</FormLabel>
<span className="text-xs text-muted-foreground">
({services.length} services)
</span>
</div>
</FormItem>
)}
/>
{/* Services */}
<div className="ml-4 space-y-2">
{services.length === 0 && (
<p className="text-xs text-muted-foreground">
No services found
</p>
)}
{services.map(
(service, serviceIndex) => (
<FormField
key={`service-${serviceIndex}`}
control={form.control}
name="accessedServices"
render={({
field: serviceField,
}) => {
return (
<FormItem
key={service.id}
className="flex flex-row items-center space-x-3 space-y-0"
>
<FormControl>
<Checkbox
checked={serviceField.value?.includes(
service.id,
)}
onCheckedChange={(
checked,
) => {
if (checked) {
// Add the service
serviceField.onChange(
[
...(serviceField.value ||
[]),
service.id,
],
);
// Auto-select the environment if not already selected
const currentEnvs =
form.getValues(
"accessedEnvironments",
) || [];
if (
!currentEnvs.includes(
environment.environmentId,
)
) {
form.setValue(
"accessedEnvironments",
[
...currentEnvs,
environment.environmentId,
],
);
}
// Auto-select the project if not already selected
const currentProjects =
form.getValues(
"accessedProjects",
) || [];
if (
!currentProjects.includes(
project.projectId,
)
) {
form.setValue(
"accessedProjects",
[
...currentProjects,
project.projectId,
],
);
}
} else {
// Remove the service
serviceField.onChange(
serviceField.value?.filter(
(value) =>
value !==
service.id,
),
);
}
}}
/>
</FormControl>
<div className="flex items-center gap-2">
<div
className={`w-1.5 h-1.5 rounded-full ${
service.type ===
"application"
? "bg-green-500"
: service.type ===
"compose"
? "bg-purple-500"
: "bg-orange-500"
}`}
/>
<FormLabel className="text-sm text-muted-foreground cursor-pointer">
{service.name}
</FormLabel>
<span className="text-xs text-muted-foreground/70 capitalize">
({service.type})
</span>
</div>
</FormItem>
);
}}
/>
</FormControl>
<FormLabel className="text-sm text-muted-foreground">
{item.name}
</FormLabel>
</FormItem>
}}
/>
),
)}
</div>
</div>
);
}}
/>
))}
},
)}
</div>
</FormItem>
);
}}
@@ -13,7 +13,7 @@ import { SidebarTrigger } from "@/components/ui/sidebar";
interface Props {
list: {
name: string;
href: string;
href?: string;
}[];
}
@@ -29,11 +29,11 @@ export const BreadcrumbSidebar = ({ list }: Props) => {
{list.map((item, index) => (
<Fragment key={item.name}>
<BreadcrumbItem className="block">
<BreadcrumbLink href={item.href} asChild={!!item.href}>
<BreadcrumbLink href={item?.href} asChild={!!item?.href}>
{item.href ? (
<Link href={item.href}>{item.name}</Link>
<Link href={item?.href}>{item?.name}</Link>
) : (
item.name
item?.name
)}
</BreadcrumbLink>
</BreadcrumbItem>