mirror of
https://github.com/andwati/dokploy.git
synced 2026-10-31 09:12:15 +00:00
Merge branch 'canary' into Issue-1852-Harikrishnan
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user