mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
feat: initial commit
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { toast } from "sonner";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
const AddRedirectchema = z.object({
|
||||
command: z.string(),
|
||||
});
|
||||
|
||||
type AddCommand = z.infer<typeof AddRedirectchema>;
|
||||
|
||||
export const AddCommand = ({ applicationId }: Props) => {
|
||||
const { data } = api.application.one.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { mutateAsync, isLoading } = api.application.update.useMutation();
|
||||
|
||||
const form = useForm<AddCommand>({
|
||||
defaultValues: {
|
||||
command: "",
|
||||
},
|
||||
resolver: zodResolver(AddRedirectchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.command) {
|
||||
form.reset({
|
||||
command: data?.command || "",
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
|
||||
|
||||
const onSubmit = async (data: AddCommand) => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
command: data?.command,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Command Updated");
|
||||
await utils.application.one.invalidate({
|
||||
applicationId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to update the command");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">Run Command</CardTitle>
|
||||
<CardDescription>
|
||||
Run a custom command in the container
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="command"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Command</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Custom command" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button isLoading={isLoading} type="submit" className="w-fit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
220
components/dashboard/application/advanced/ports/add-port.tsx
Normal file
220
components/dashboard/application/advanced/ports/add-port.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { z } from "zod";
|
||||
|
||||
const AddPortchema = z.object({
|
||||
publishedPort: z.number().int().min(1).max(65535),
|
||||
targetPort: z.number().int().min(1).max(65535),
|
||||
protocol: z.enum(["tcp", "udp"], {
|
||||
required_error: "Protocol is required",
|
||||
}),
|
||||
});
|
||||
|
||||
type AddPort = z.infer<typeof AddPortchema>;
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AddPort = ({
|
||||
applicationId,
|
||||
children = <PlusIcon className="h-4 w-4" />,
|
||||
}: Props) => {
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.port.create.useMutation();
|
||||
|
||||
const form = useForm<AddPort>({
|
||||
defaultValues: {
|
||||
publishedPort: 0,
|
||||
targetPort: 0,
|
||||
},
|
||||
resolver: zodResolver(AddPortchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
publishedPort: 0,
|
||||
targetPort: 0,
|
||||
});
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful]);
|
||||
|
||||
const onSubmit = async (data: AddPort) => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
...data,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Port Created");
|
||||
await utils.application.one.invalidate({
|
||||
applicationId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to create the port");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button>{children}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ports</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ports are used to expose your application to the internet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && (
|
||||
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
|
||||
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
{error?.message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-add-port"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="publishedPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Published Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="1-65535"
|
||||
{...field}
|
||||
value={field.value?.toString() || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") {
|
||||
field.onChange(0);
|
||||
} else {
|
||||
const number = Number.parseInt(value, 10);
|
||||
if (!Number.isNaN(number)) {
|
||||
field.onChange(number);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="targetPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Target Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="1-65535"
|
||||
{...field}
|
||||
value={field.value?.toString() || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") {
|
||||
field.onChange(0);
|
||||
} else {
|
||||
const number = Number.parseInt(value, 10);
|
||||
if (!Number.isNaN(number)) {
|
||||
field.onChange(number);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="protocol"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormLabel>Protocol</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a protocol" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent defaultValue={"none"}>
|
||||
<SelectItem value={"none"}>None</SelectItem>
|
||||
<SelectItem value={"tcp"}>TCP</SelectItem>
|
||||
<SelectItem value={"udp"}>UDP</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-add-port"
|
||||
type="submit"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
portId: string;
|
||||
}
|
||||
|
||||
export const DeletePort = ({ portId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, isLoading } = api.port.delete.useMutation();
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" isLoading={isLoading}>
|
||||
<TrashIcon className="size-4 text-muted-foreground " />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the port
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
portId,
|
||||
})
|
||||
.then((data) => {
|
||||
utils.application.one.invalidate({
|
||||
applicationId: data?.applicationId,
|
||||
});
|
||||
|
||||
toast.success("Port delete succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to delete the port");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { Rss } from "lucide-react";
|
||||
import { AddPort } from "./add-port";
|
||||
import { DeletePort } from "./delete-port";
|
||||
import { UpdatePort } from "./update-port";
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const ShowPorts = ({ applicationId }: Props) => {
|
||||
const { data } = api.application.one.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-xl">Ports</CardTitle>
|
||||
<CardDescription>
|
||||
the ports allows you to expose your application to the internet
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{data && data?.ports.length > 0 && (
|
||||
<AddPort applicationId={applicationId}>Add Port</AddPort>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{data?.ports.length === 0 ? (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
||||
<Rss className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
No ports configured
|
||||
</span>
|
||||
<AddPort applicationId={applicationId}>Add Port</AddPort>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col pt-2">
|
||||
<div className="flex flex-col gap-6">
|
||||
{data?.ports.map((port) => (
|
||||
<div key={port.portId}>
|
||||
<div className="flex w-full flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-10 border rounded-lg p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 flex-col gap-4 sm:gap-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Published Port</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{port.publishedPort}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium"> Target Port</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{port.targetPort}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Protocol</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{port.protocol.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-4">
|
||||
<UpdatePort portId={port.portId} />
|
||||
<DeletePort portId={port.portId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
228
components/dashboard/application/advanced/ports/update-port.tsx
Normal file
228
components/dashboard/application/advanced/ports/update-port.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AlertTriangle, Pencil } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
const UpdatePortSchema = z.object({
|
||||
publishedPort: z.number().int().min(1).max(65535),
|
||||
targetPort: z.number().int().min(1).max(65535),
|
||||
protocol: z.enum(["tcp", "udp"], {
|
||||
required_error: "Protocol is required",
|
||||
invalid_type_error: "Protocol must be a valid protocol",
|
||||
}),
|
||||
});
|
||||
|
||||
type UpdatePort = z.infer<typeof UpdatePortSchema>;
|
||||
|
||||
interface Props {
|
||||
portId: string;
|
||||
}
|
||||
|
||||
export const UpdatePort = ({ portId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { data } = api.port.one.useQuery(
|
||||
{
|
||||
portId,
|
||||
},
|
||||
{
|
||||
enabled: !!portId,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.port.update.useMutation();
|
||||
|
||||
const form = useForm<UpdatePort>({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(UpdatePortSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
publishedPort: data.publishedPort,
|
||||
targetPort: data.targetPort,
|
||||
protocol: data.protocol,
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const onSubmit = async (data: UpdatePort) => {
|
||||
await mutateAsync({
|
||||
portId,
|
||||
publishedPort: data.publishedPort,
|
||||
targetPort: data.targetPort,
|
||||
protocol: data.protocol,
|
||||
})
|
||||
.then(async (response) => {
|
||||
toast.success("Port Updated");
|
||||
await utils.application.one.invalidate({
|
||||
applicationId: response?.applicationId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to update the port");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" isLoading={isLoading}>
|
||||
<Pencil className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update</DialogTitle>
|
||||
<DialogDescription>Update the port</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && (
|
||||
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
|
||||
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
{error?.message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-update-redirect"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="publishedPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Published Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="1-65535"
|
||||
{...field}
|
||||
value={field.value?.toString() || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") {
|
||||
field.onChange(0);
|
||||
} else {
|
||||
const number = Number.parseInt(value, 10);
|
||||
if (!Number.isNaN(number)) {
|
||||
field.onChange(number);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="targetPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Target Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="1-65535"
|
||||
{...field}
|
||||
value={field.value?.toString() || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") {
|
||||
field.onChange(0);
|
||||
} else {
|
||||
const number = Number.parseInt(value, 10);
|
||||
if (!Number.isNaN(number)) {
|
||||
field.onChange(number);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="protocol"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormLabel>Protocol</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a protocol" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent defaultValue={"none"}>
|
||||
<SelectItem value={"none"} disabled>
|
||||
None
|
||||
</SelectItem>
|
||||
<SelectItem value={"tcp"}>TCP</SelectItem>
|
||||
<SelectItem value={"udp"}>UDP</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-update-redirect"
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
const AddRedirectchema = z.object({
|
||||
regex: z.string().min(1, "Regex required"),
|
||||
permanent: z.boolean().default(false),
|
||||
replacement: z.string().min(1, "Replacement required"),
|
||||
});
|
||||
|
||||
type AddRedirect = z.infer<typeof AddRedirectchema>;
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AddRedirect = ({
|
||||
applicationId,
|
||||
children = <PlusIcon className="h-4 w-4" />,
|
||||
}: Props) => {
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.redirects.create.useMutation();
|
||||
|
||||
const form = useForm<AddRedirect>({
|
||||
defaultValues: {
|
||||
permanent: false,
|
||||
regex: "",
|
||||
replacement: "",
|
||||
},
|
||||
resolver: zodResolver(AddRedirectchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
permanent: false,
|
||||
regex: "",
|
||||
replacement: "",
|
||||
});
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful]);
|
||||
|
||||
const onSubmit = async (data: AddRedirect) => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
...data,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Redirect Created");
|
||||
await utils.application.one.invalidate({
|
||||
applicationId,
|
||||
});
|
||||
await utils.application.readTraefikConfig.invalidate({
|
||||
applicationId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to create the redirect");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button>{children}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Redirects</DialogTitle>
|
||||
<DialogDescription>
|
||||
Redirects are used to redirect requests to another url.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && (
|
||||
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
|
||||
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
{error?.message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-add-redirect"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="regex"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Regex</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="^http://localhost/(.*)" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="replacement"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Replacement</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="http://mydomain/$${1}" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="permanent"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Permanent</FormLabel>
|
||||
<FormDescription>
|
||||
Set the permanent option to true to apply a permanent
|
||||
redirection.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-add-redirect"
|
||||
type="submit"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
redirectId: string;
|
||||
}
|
||||
|
||||
export const DeleteRedirect = ({ redirectId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, isLoading } = api.redirects.delete.useMutation();
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" isLoading={isLoading}>
|
||||
<TrashIcon className="size-4 text-muted-foreground " />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
redirect
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
redirectId,
|
||||
})
|
||||
.then((data) => {
|
||||
utils.application.one.invalidate({
|
||||
applicationId: data?.applicationId,
|
||||
});
|
||||
utils.application.readTraefikConfig.invalidate({
|
||||
applicationId: data?.applicationId,
|
||||
});
|
||||
toast.success("Redirect delete succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to delete the redirect");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { Split } from "lucide-react";
|
||||
import { AddRedirect } from "./add-redirect";
|
||||
import { DeleteRedirect } from "./delete-redirect";
|
||||
import { UpdateRedirect } from "./update-redirect";
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const ShowRedirects = ({ applicationId }: Props) => {
|
||||
const { data } = api.application.one.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-xl">Redirects</CardTitle>
|
||||
<CardDescription>
|
||||
If you want to redirect requests to this application use the
|
||||
following config to setup the redirects
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{data && data?.redirects.length > 0 && (
|
||||
<AddRedirect applicationId={applicationId}>Add Redirect</AddRedirect>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{data?.redirects.length === 0 ? (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
||||
<Split className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
No redirects configured
|
||||
</span>
|
||||
<AddRedirect applicationId={applicationId}>
|
||||
Add Redirect
|
||||
</AddRedirect>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col pt-2">
|
||||
<div className="flex flex-col gap-6">
|
||||
{data?.redirects.map((redirect) => (
|
||||
<div key={redirect.redirectId}>
|
||||
<div className="flex w-full flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-10 border rounded-lg p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 flex-col gap-4 sm:gap-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Regex</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{redirect.regex}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Replacement</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{redirect.replacement}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Permanent</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{redirect.permanent ? "Yes" : "No"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-4">
|
||||
<UpdateRedirect redirectId={redirect.redirectId} />
|
||||
<DeleteRedirect redirectId={redirect.redirectId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AlertTriangle, Pencil } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
const UpdateRedirectSchema = z.object({
|
||||
regex: z.string().min(1, "Regex required"),
|
||||
permanent: z.boolean().default(false),
|
||||
replacement: z.string().min(1, "Replacement required"),
|
||||
});
|
||||
|
||||
type UpdateRedirect = z.infer<typeof UpdateRedirectSchema>;
|
||||
|
||||
interface Props {
|
||||
redirectId: string;
|
||||
}
|
||||
|
||||
export const UpdateRedirect = ({ redirectId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { data } = api.redirects.one.useQuery(
|
||||
{
|
||||
redirectId,
|
||||
},
|
||||
{
|
||||
enabled: !!redirectId,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.redirects.update.useMutation();
|
||||
|
||||
const form = useForm<UpdateRedirect>({
|
||||
defaultValues: {
|
||||
permanent: false,
|
||||
regex: "",
|
||||
replacement: "",
|
||||
},
|
||||
resolver: zodResolver(UpdateRedirectSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
permanent: data.permanent || false,
|
||||
regex: data.regex || "",
|
||||
replacement: data.replacement || "",
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const onSubmit = async (data: UpdateRedirect) => {
|
||||
await mutateAsync({
|
||||
redirectId,
|
||||
permanent: data.permanent,
|
||||
regex: data.regex,
|
||||
replacement: data.replacement,
|
||||
})
|
||||
.then(async (response) => {
|
||||
toast.success("Redirect Updated");
|
||||
await utils.application.one.invalidate({
|
||||
applicationId: response?.applicationId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to update the redirect");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" isLoading={isLoading}>
|
||||
<Pencil className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update</DialogTitle>
|
||||
<DialogDescription>Update the redirect</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && (
|
||||
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
|
||||
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
{error?.message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-update-redirect"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="regex"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Regex</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="^http://localhost/(.*)" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="replacement"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Replacement</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="http://mydomain/$${1}" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="permanent"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Permanent</FormLabel>
|
||||
<FormDescription>
|
||||
Set the permanent option to true to apply a permanent
|
||||
redirection.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-update-redirect"
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
|
||||
const AddSecuritychema = z.object({
|
||||
username: z.string().min(1, "Username is required"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
});
|
||||
|
||||
type AddSecurity = z.infer<typeof AddSecuritychema>;
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AddSecurity = ({
|
||||
applicationId,
|
||||
children = <PlusIcon className="h-4 w-4" />,
|
||||
}: Props) => {
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.security.create.useMutation();
|
||||
|
||||
const form = useForm<AddSecurity>({
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
resolver: zodResolver(AddSecuritychema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset();
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful]);
|
||||
|
||||
const onSubmit = async (data: AddSecurity) => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
...data,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Security Created");
|
||||
await utils.application.one.invalidate({
|
||||
applicationId,
|
||||
});
|
||||
await utils.application.readTraefikConfig.invalidate({
|
||||
applicationId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to create the security");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button>{children}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Security</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add security to your application
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && (
|
||||
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
|
||||
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
{error?.message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-add-security"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="test1" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="test" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-add-security"
|
||||
type="submit"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
securityId: string;
|
||||
}
|
||||
|
||||
export const DeleteSecurity = ({ securityId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, isLoading } = api.security.delete.useMutation();
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" isLoading={isLoading}>
|
||||
<TrashIcon className="size-4 text-muted-foreground " />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
security
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
securityId,
|
||||
})
|
||||
.then((data) => {
|
||||
utils.application.one.invalidate({
|
||||
applicationId: data?.applicationId,
|
||||
});
|
||||
utils.application.readTraefikConfig.invalidate({
|
||||
applicationId: data?.applicationId,
|
||||
});
|
||||
toast.success("Security delete succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to delete the security");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { LockKeyhole } from "lucide-react";
|
||||
import { AddSecurity } from "./add-security";
|
||||
import { DeleteSecurity } from "./delete-security";
|
||||
import { UpdateSecurity } from "./update-security";
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const ShowSecurity = ({ applicationId }: Props) => {
|
||||
const { data } = api.application.one.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-xl">Security</CardTitle>
|
||||
<CardDescription>Add basic auth to your application</CardDescription>
|
||||
</div>
|
||||
|
||||
{data && data?.security.length > 0 && (
|
||||
<AddSecurity applicationId={applicationId}>Add Security</AddSecurity>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{data?.security.length === 0 ? (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
||||
<LockKeyhole className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
No security configured
|
||||
</span>
|
||||
<AddSecurity applicationId={applicationId}>
|
||||
Add Security
|
||||
</AddSecurity>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col pt-2">
|
||||
<div className="flex flex-col gap-6 ">
|
||||
{data?.security.map((security) => (
|
||||
<div key={security.securityId}>
|
||||
<div className="flex w-full flex-col sm:flex-row justify-between sm:items-center gap-4 sm:gap-10 border rounded-lg p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 flex-col gap-4 sm:gap-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Username</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{security.username}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Password</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{security.password}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2">
|
||||
<UpdateSecurity securityId={security.securityId} />
|
||||
<DeleteSecurity securityId={security.securityId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AlertTriangle, Pencil } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const UpdateSecuritySchema = z.object({
|
||||
username: z.string().min(1, "Username is required"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
});
|
||||
|
||||
type UpdateSecurity = z.infer<typeof UpdateSecuritySchema>;
|
||||
|
||||
interface Props {
|
||||
securityId: string;
|
||||
}
|
||||
|
||||
export const UpdateSecurity = ({ securityId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { data } = api.security.one.useQuery(
|
||||
{
|
||||
securityId,
|
||||
},
|
||||
{
|
||||
enabled: !!securityId,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.security.update.useMutation();
|
||||
|
||||
const form = useForm<UpdateSecurity>({
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
resolver: zodResolver(UpdateSecuritySchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
username: data.username || "",
|
||||
password: data.password || "",
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const onSubmit = async (data: UpdateSecurity) => {
|
||||
await mutateAsync({
|
||||
securityId,
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
})
|
||||
.then(async (response) => {
|
||||
toast.success("Security Updated");
|
||||
await utils.application.one.invalidate({
|
||||
applicationId: response?.applicationId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to update the security");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" isLoading={isLoading}>
|
||||
<Pencil className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update</DialogTitle>
|
||||
<DialogDescription>Update the security</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && (
|
||||
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
|
||||
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
{error?.message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-update-security"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4 "
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="test1" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="test" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-update-security"
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import React, { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const addResourcesApplication = z.object({
|
||||
memoryReservation: z.number().nullable().optional(),
|
||||
cpuLimit: z.number().nullable().optional(),
|
||||
memoryLimit: z.number().nullable().optional(),
|
||||
cpuReservation: z.number().nullable().optional(),
|
||||
});
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
type AddResourcesApplication = z.infer<typeof addResourcesApplication>;
|
||||
|
||||
export const ShowApplicationResources = ({ applicationId }: Props) => {
|
||||
const { data, refetch } = api.application.one.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
const { mutateAsync, isLoading } = api.application.update.useMutation();
|
||||
const form = useForm<AddResourcesApplication>({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(addResourcesApplication),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
cpuLimit: data?.cpuLimit || undefined,
|
||||
cpuReservation: data?.cpuReservation || undefined,
|
||||
memoryLimit: data?.memoryLimit || undefined,
|
||||
memoryReservation: data?.memoryReservation || undefined,
|
||||
});
|
||||
}
|
||||
}, [data, form, form.reset]);
|
||||
|
||||
const onSubmit = async (formData: AddResourcesApplication) => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
cpuLimit: formData.cpuLimit || null,
|
||||
cpuReservation: formData.cpuReservation || null,
|
||||
memoryLimit: formData.memoryLimit || null,
|
||||
memoryReservation: formData.memoryReservation || null,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Resources Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to Update the resources");
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Resources</CardTitle>
|
||||
<CardDescription>
|
||||
If you want to decrease or increase the resources to a specific
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-8 "
|
||||
>
|
||||
<div className="grid w-full md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="memoryReservation"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Memory Reservation</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="256 MB"
|
||||
{...field}
|
||||
value={field.value?.toString() || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") {
|
||||
field.onChange(null);
|
||||
} else {
|
||||
const number = Number.parseInt(value, 10);
|
||||
if (!Number.isNaN(number)) {
|
||||
field.onChange(number);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="memoryLimit"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Memory Limit</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"1024 MB"}
|
||||
{...field}
|
||||
value={field.value?.toString() || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") {
|
||||
field.onChange(null);
|
||||
} else {
|
||||
const number = Number.parseInt(value, 10);
|
||||
if (!Number.isNaN(number)) {
|
||||
field.onChange(number);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cpuLimit"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Cpu Limit</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"2"}
|
||||
{...field}
|
||||
type="number"
|
||||
value={field.value?.toString() || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (
|
||||
value === "" ||
|
||||
/^[0-9]*\.?[0-9]*$/.test(value)
|
||||
) {
|
||||
const float = Number.parseFloat(value);
|
||||
field.onChange(float);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cpuReservation"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Cpu Reservation</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"1"}
|
||||
{...field}
|
||||
type="number"
|
||||
value={field.value?.toString() || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (
|
||||
value === "" ||
|
||||
/^[0-9]*\.?[0-9]*$/.test(value)
|
||||
) {
|
||||
const float = Number.parseFloat(value);
|
||||
field.onChange(float);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { File } from "lucide-react";
|
||||
import { UpdateTraefikConfig } from "./update-traefik-config";
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const ShowTraefikConfig = ({ applicationId }: Props) => {
|
||||
const { data } = api.application.readTraefikConfig.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">Traefik</CardTitle>
|
||||
<CardDescription>
|
||||
Modify the traefik config, in rare cases you may need to add
|
||||
specific config, becarefull because modifying incorrectly can break
|
||||
traefik and your application
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{data === null ? (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
||||
<File className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
No traefik config detected
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col pt-2 relative">
|
||||
<div className="flex flex-col gap-6 bg-input p-4 rounded-md max-h-[35rem] min-h-[10rem] overflow-y-auto">
|
||||
<div>
|
||||
<pre className="font-sans">{data || "Empty"}</pre>
|
||||
</div>
|
||||
<div className="flex justify-end absolute z-50 right-6">
|
||||
<UpdateTraefikConfig applicationId={applicationId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import jsyaml from "js-yaml";
|
||||
|
||||
const UpdateTraefikConfigSchema = z.object({
|
||||
traefikConfig: z.string(),
|
||||
});
|
||||
|
||||
type UpdateTraefikConfig = z.infer<typeof UpdateTraefikConfigSchema>;
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const validateAndFormatYAML = (yamlText: string) => {
|
||||
try {
|
||||
const obj = jsyaml.load(yamlText);
|
||||
const formattedYaml = jsyaml.dump(obj, { indent: 4 });
|
||||
return { valid: true, formattedYaml, error: null };
|
||||
} catch (error) {
|
||||
if (error instanceof jsyaml.YAMLException) {
|
||||
return {
|
||||
valid: false,
|
||||
formattedYaml: yamlText,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
return {
|
||||
valid: false,
|
||||
formattedYaml: yamlText,
|
||||
error: "An unexpected error occurred while processing the YAML.",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
const { data, refetch } = api.application.readTraefikConfig.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.application.updateTraefikConfig.useMutation();
|
||||
|
||||
const form = useForm<UpdateTraefikConfig>({
|
||||
defaultValues: {
|
||||
traefikConfig: "",
|
||||
},
|
||||
resolver: zodResolver(UpdateTraefikConfigSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
traefikConfig: data || "",
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const onSubmit = async (data: UpdateTraefikConfig) => {
|
||||
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: error || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
}
|
||||
form.clearErrors("traefikConfig");
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
traefikConfig: data.traefikConfig,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Traefik config Updated");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to update the traefik config");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button isLoading={isLoading}>Modify</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update traefik config</DialogTitle>
|
||||
<DialogDescription>Update the traefik config</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && (
|
||||
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
|
||||
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
{error?.message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-update-traefik-config"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full py-4"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="traefikConfig"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Traefik config</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="h-[35rem]"
|
||||
placeholder={`http:
|
||||
routers:
|
||||
router-name:
|
||||
rule: Host('domain.com')
|
||||
service: container-name
|
||||
entryPoints:
|
||||
- web
|
||||
tls: false
|
||||
middlewares: []
|
||||
`}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<pre>
|
||||
<FormMessage />
|
||||
</pre>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-update-traefik-config"
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,330 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
interface Props {
|
||||
serviceId: string;
|
||||
serviceType:
|
||||
| "application"
|
||||
| "postgres"
|
||||
| "redis"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "mysql"
|
||||
| "mariadb";
|
||||
refetch: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const mountSchema = z.object({
|
||||
mountPath: z.string().min(1, "Mount path required"),
|
||||
});
|
||||
|
||||
const mySchema = z.discriminatedUnion("type", [
|
||||
z
|
||||
.object({
|
||||
type: z.literal("bind"),
|
||||
hostPath: z.string().min(1, "Host path required"),
|
||||
})
|
||||
.merge(mountSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("volume"),
|
||||
volumeName: z.string().min(1, "Volume name required"),
|
||||
})
|
||||
.merge(mountSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("file"),
|
||||
content: z.string().optional(),
|
||||
})
|
||||
.merge(mountSchema),
|
||||
]);
|
||||
|
||||
type AddMount = z.infer<typeof mySchema>;
|
||||
|
||||
export const AddVolumes = ({
|
||||
serviceId,
|
||||
serviceType,
|
||||
refetch,
|
||||
children = <PlusIcon className="h-4 w-4" />,
|
||||
}: Props) => {
|
||||
const { mutateAsync } = api.mounts.create.useMutation();
|
||||
const form = useForm<AddMount>({
|
||||
defaultValues: {
|
||||
type: "bind",
|
||||
hostPath: "",
|
||||
mountPath: "",
|
||||
},
|
||||
resolver: zodResolver(mySchema),
|
||||
});
|
||||
const type = form.watch("type");
|
||||
|
||||
useEffect(() => {
|
||||
form.reset();
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful]);
|
||||
|
||||
const onSubmit = async (data: AddMount) => {
|
||||
if (data.type === "bind") {
|
||||
await mutateAsync({
|
||||
serviceId,
|
||||
hostPath: data.hostPath,
|
||||
mountPath: data.mountPath,
|
||||
type: data.type,
|
||||
serviceType,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mount Created");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to create the Bind mount");
|
||||
});
|
||||
} else if (data.type === "volume") {
|
||||
await mutateAsync({
|
||||
serviceId,
|
||||
volumeName: data.volumeName,
|
||||
mountPath: data.mountPath,
|
||||
type: data.type,
|
||||
serviceType,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mount Created");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to create the Volume mount");
|
||||
});
|
||||
} else if (data.type === "file") {
|
||||
await mutateAsync({
|
||||
serviceId,
|
||||
content: data.content,
|
||||
mountPath: data.mountPath,
|
||||
type: data.type,
|
||||
serviceType,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mount Created");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to create the File mount");
|
||||
});
|
||||
}
|
||||
|
||||
refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger className="" asChild>
|
||||
<Button>{children}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Volumes / Mounts</DialogTitle>
|
||||
</DialogHeader>
|
||||
{/* {isError && (
|
||||
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
|
||||
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
{error?.message}
|
||||
</span>
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-volume"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-8 "
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
defaultValue={form.control._defaultValues.type}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormLabel className="text-muted-foreground">
|
||||
Select the Mount Type
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="grid w-full grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
|
||||
>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl className="w-full">
|
||||
<div>
|
||||
<RadioGroupItem
|
||||
value="bind"
|
||||
id="bind"
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="bind"
|
||||
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
|
||||
>
|
||||
Bind Mount
|
||||
</Label>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl className="w-full">
|
||||
<div>
|
||||
<RadioGroupItem
|
||||
value="volume"
|
||||
id="volume"
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="volume"
|
||||
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
|
||||
>
|
||||
Volume Mount
|
||||
</Label>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl className="w-full">
|
||||
<div>
|
||||
<RadioGroupItem
|
||||
value="file"
|
||||
id="file"
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="file"
|
||||
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
|
||||
>
|
||||
File Mount
|
||||
</Label>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormLabel className="text-lg font-semibold leading-none tracking-tight">
|
||||
Fill the next fields.
|
||||
</FormLabel>
|
||||
<div className="flex flex-col gap-2">
|
||||
{type === "bind" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hostPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Host Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Host Path" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{type === "volume" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="volumeName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Volume Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Volume Name"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{type === "file" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Content</FormLabel>
|
||||
<FormControl>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Any content"
|
||||
className="h-64"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mountPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Mount Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Mount Path" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={form.formState.isSubmitting}
|
||||
form="hook-form-volume"
|
||||
type="submit"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
mountId: string;
|
||||
refetch: () => void;
|
||||
}
|
||||
export const DeleteVolume = ({ mountId, refetch }: Props) => {
|
||||
const { mutateAsync, isLoading } = api.mounts.remove.useMutation();
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" isLoading={isLoading}>
|
||||
<TrashIcon className="size-4 text-muted-foreground " />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the mount
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
mountId,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success("Mount deleted succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to delete the mount");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { AlertTriangle, Package } from "lucide-react";
|
||||
import { AddVolumes } from "./add-volumes";
|
||||
import { DeleteVolume } from "./delete-volume";
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const ShowVolumes = ({ applicationId }: Props) => {
|
||||
const { data, refetch } = api.application.one.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-xl">Volumes</CardTitle>
|
||||
<CardDescription>
|
||||
If you want to persist data in this application use the following
|
||||
config to setup the volumes
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{data && data?.mounts.length > 0 && (
|
||||
<AddVolumes
|
||||
serviceId={applicationId}
|
||||
refetch={refetch}
|
||||
serviceType="application"
|
||||
>
|
||||
Add Volume
|
||||
</AddVolumes>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{data?.mounts.length === 0 ? (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
||||
<Package className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
No volumes/mounts configured
|
||||
</span>
|
||||
<AddVolumes
|
||||
serviceId={applicationId}
|
||||
refetch={refetch}
|
||||
serviceType="application"
|
||||
>
|
||||
Add Volume
|
||||
</AddVolumes>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col pt-2">
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950">
|
||||
<AlertTriangle className="text-yellow-600 size-5 sm:size-8 dark:text-yellow-400" />
|
||||
<span className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
Please remember to click Redeploy after adding, editing, or
|
||||
deleting a mount to apply the changes.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 pt-6">
|
||||
{data?.mounts.map((mount) => (
|
||||
<div key={mount.mountId}>
|
||||
<div
|
||||
key={mount.mountId}
|
||||
className="flex w-full flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-10 border rounded-lg p-4"
|
||||
>
|
||||
{/* <Package className="size-8 self-center text-muted-foreground" /> */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 flex-col gap-4 sm:gap-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Mount Type</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{mount.type.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
{mount.type === "volume" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Volume Name</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{mount.volumeName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mount.type === "file" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Content</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{mount.content}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{mount.type === "bind" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Host Path</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{mount.hostPath}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Mount Path</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{mount.mountPath}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
213
components/dashboard/application/build/show.tsx
Normal file
213
components/dashboard/application/build/show.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Cog } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { toast } from "sonner";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
enum BuildType {
|
||||
dockerfile = "dockerfile",
|
||||
heroku_buildpacks = "heroku_buildpacks",
|
||||
paketo_buildpacks = "paketo_buildpacks",
|
||||
nixpacks = "nixpacks",
|
||||
}
|
||||
|
||||
const mySchema = z.discriminatedUnion("buildType", [
|
||||
z.object({
|
||||
buildType: z.literal("dockerfile"),
|
||||
dockerfile: z
|
||||
.string({
|
||||
required_error: "Dockerfile path is required",
|
||||
invalid_type_error: "Dockerfile path is required",
|
||||
})
|
||||
.min(1, "Dockerfile required"),
|
||||
}),
|
||||
z.object({
|
||||
buildType: z.literal("heroku_buildpacks"),
|
||||
}),
|
||||
z.object({
|
||||
buildType: z.literal("paketo_buildpacks"),
|
||||
}),
|
||||
z.object({
|
||||
buildType: z.literal("nixpacks"),
|
||||
}),
|
||||
]);
|
||||
|
||||
type AddTemplate = z.infer<typeof mySchema>;
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
const { mutateAsync, isLoading } =
|
||||
api.application.saveBuildType.useMutation();
|
||||
const { data, refetch } = api.application.one.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{
|
||||
enabled: !!applicationId,
|
||||
},
|
||||
);
|
||||
|
||||
const form = useForm<AddTemplate>({
|
||||
defaultValues: {
|
||||
buildType: BuildType.nixpacks,
|
||||
},
|
||||
resolver: zodResolver(mySchema),
|
||||
});
|
||||
|
||||
const buildType = form.watch("buildType");
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
// TODO: refactor this
|
||||
if (data.buildType === "dockerfile") {
|
||||
form.reset({
|
||||
buildType: data.buildType,
|
||||
...(data.buildType && {
|
||||
dockerfile: data.dockerfile || "",
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
form.reset({
|
||||
buildType: data.buildType,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [form.formState.isSubmitSuccessful, form.reset, data, form]);
|
||||
|
||||
const onSubmit = async (data: AddTemplate) => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
buildType: data.buildType,
|
||||
dockerfile: data.buildType === "dockerfile" ? data.dockerfile : null,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Build type saved");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to save the build type");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="group relative w-full bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-start justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="flex flex-col space-y-0.5">Build Type</span>
|
||||
<p className="flex items-center text-sm font-normal text-muted-foreground">
|
||||
Select the way of building your code
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden space-y-1 text-sm font-normal md:block">
|
||||
<Cog className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4 p-2"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="buildType"
|
||||
defaultValue={form.control._defaultValues.buildType}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem className="space-y-3">
|
||||
<FormLabel>Build Type</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
className="flex flex-col space-y-1"
|
||||
>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="dockerfile" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
Dockerfile
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="nixpacks" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
Nixpacks
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="heroku_buildpacks" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
Heroku Buildpacks
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="paketo_buildpacks" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
Paketo Buildpacks
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{buildType === "dockerfile" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dockerfile"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Docker File</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"Path of your docker file"}
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex w-full justify-end">
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
63
components/dashboard/application/delete-application.tsx
Normal file
63
components/dashboard/application/delete-application.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const DeleteApplication = ({ applicationId }: Props) => {
|
||||
const { mutateAsync, isLoading } = api.application.delete.useMutation();
|
||||
const { push } = useRouter();
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" isLoading={isLoading}>
|
||||
<TrashIcon className="size-4 " />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
application
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
})
|
||||
.then((data) => {
|
||||
push(`/dashboard/project/${data?.projectId}`);
|
||||
|
||||
toast.success("Application delete succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to delete Application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { Paintbrush } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const CancelQueues = ({ applicationId }: Props) => {
|
||||
const { mutateAsync, isLoading } = api.application.cleanQueues.useMutation();
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="w-fit" isLoading={isLoading}>
|
||||
Cancel Queues
|
||||
<Paintbrush className="size-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure to cancel the incoming deployments?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will cancel all the incoming deployments
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Queues are being cleaned");
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { api } from "@/utils/api";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
export const RefreshToken = ({ applicationId }: Props) => {
|
||||
const { mutateAsync } = api.application.refreshToken.useMutation();
|
||||
const utils = api.useUtils();
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger>
|
||||
<RefreshCcw className="h-4 w-4 cursor-pointer text-muted-foreground" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
domain
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
})
|
||||
.then(() => {
|
||||
utils.application.one.invalidate({
|
||||
applicationId,
|
||||
});
|
||||
toast.success("Refresh updated");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to update the refresh token");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
logPath: string | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
export const ShowDeployment = ({ logPath, open, onClose }: Props) => {
|
||||
const [data, setData] = useState("");
|
||||
const endOfLogsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !logPath) return;
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
|
||||
const wsUrl = `${protocol}//${window.location.host}/listen-deployment?logPath=${logPath}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
setData((currentData) => currentData + e.data);
|
||||
};
|
||||
|
||||
return () => ws.close();
|
||||
}, [logPath, open]);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
endOfLogsRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(e) => {
|
||||
onClose();
|
||||
if (!e) setData("");
|
||||
}}
|
||||
>
|
||||
<DialogContent className={"sm:max-w-5xl overflow-y-auto max-h-screen"}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Deployment</DialogTitle>
|
||||
<DialogDescription>
|
||||
See all the details of this deployment
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem]">
|
||||
<code>
|
||||
<pre className="whitespace-pre-wrap break-words">
|
||||
{data || "Loading..."}
|
||||
</pre>
|
||||
<div ref={endOfLogsRef} />
|
||||
</code>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { RocketIcon } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { CancelQueues } from "./cancel-queues";
|
||||
import { ShowDeployment } from "./show-deployment";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { RefreshToken } from "./refresh-token";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
export const ShowDeployments = ({ applicationId }: Props) => {
|
||||
const [activeLog, setActiveLog] = useState<string | null>(null);
|
||||
const { data } = api.application.one.useQuery({ applicationId });
|
||||
const { data: deployments } = api.deployment.all.useQuery(
|
||||
{ applicationId },
|
||||
{
|
||||
enabled: !!applicationId,
|
||||
refetchInterval: 5000,
|
||||
},
|
||||
);
|
||||
const [url, setUrl] = React.useState("");
|
||||
useEffect(() => {
|
||||
setUrl(document.location.origin);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<CardTitle className="text-xl">Deployments</CardTitle>
|
||||
<CardDescription>
|
||||
See all the 10 last deployments for this application
|
||||
</CardDescription>
|
||||
</div>
|
||||
<CancelQueues applicationId={applicationId} />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<span>
|
||||
If you want to re-deploy this application use this URL in the config
|
||||
of your git provider or docker
|
||||
</span>
|
||||
<div className="flex flex-row items-center gap-2 flex-wrap">
|
||||
<span>Webhook URL: </span>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span className="text-muted-foreground">
|
||||
{`${url}/api/deploy/${data?.refreshToken}`}
|
||||
</span>
|
||||
<RefreshToken applicationId={applicationId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{data?.deployments?.length === 0 ? (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
||||
<RocketIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
No deployments found
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{deployments?.map((deployment) => (
|
||||
<div
|
||||
key={deployment.deploymentId}
|
||||
className="flex items-center justify-between rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||
{deployment.status}
|
||||
|
||||
<StatusTooltip
|
||||
status={deployment?.status}
|
||||
className="size-2.5"
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{deployment.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="text-sm capitalize text-muted-foreground">
|
||||
<DateTooltip date={deployment.createdAt} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setActiveLog(deployment.logPath);
|
||||
}}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ShowDeployment
|
||||
open={activeLog !== null}
|
||||
onClose={() => setActiveLog(null)}
|
||||
logPath={activeLog}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
242
components/dashboard/application/domains/add-domain.tsx
Normal file
242
components/dashboard/application/domains/add-domain.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AlertTriangle, PlusIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
// const hostnameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\.-]*\.[a-zA-Z]{2,}$/;
|
||||
// .regex(hostnameRegex
|
||||
const addDomain = z.object({
|
||||
host: z.string().min(1, "Hostname is required"),
|
||||
path: z.string().min(1),
|
||||
port: z.number(),
|
||||
https: z.boolean(),
|
||||
certificateType: z.enum(["letsencrypt", "none"]),
|
||||
});
|
||||
|
||||
type AddDomain = z.infer<typeof addDomain>;
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AddDomain = ({
|
||||
applicationId,
|
||||
children = <PlusIcon className="h-4 w-4" />,
|
||||
}: Props) => {
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { mutateAsync, isError, error } = api.domain.create.useMutation();
|
||||
|
||||
const form = useForm<AddDomain>({
|
||||
defaultValues: {
|
||||
host: "",
|
||||
https: false,
|
||||
path: "/",
|
||||
port: 3000,
|
||||
certificateType: "none",
|
||||
},
|
||||
resolver: zodResolver(addDomain),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset();
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful]);
|
||||
|
||||
const onSubmit = async (data: AddDomain) => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
host: data.host,
|
||||
https: data.https,
|
||||
path: data.path,
|
||||
port: data.port,
|
||||
certificateType: data.certificateType,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Domain Created");
|
||||
await utils.domain.byApplicationId.invalidate({
|
||||
applicationId,
|
||||
});
|
||||
await utils.application.readTraefikConfig.invalidate({ applicationId });
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to create the domain");
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger className="" asChild>
|
||||
<Button>{children}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Domain</DialogTitle>
|
||||
<DialogDescription>
|
||||
In this section you can add custom domains
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && (
|
||||
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
|
||||
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
{error?.message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-8 "
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="api.dokploy.com" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="path"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"/"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Container Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"3000"}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(Number.parseInt(e.target.value));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="certificateType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>Certificate</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value || ""}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a certificate" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value={"letsencrypt"}>
|
||||
Letsencrypt (Default)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="https"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>HTTPS</FormLabel>
|
||||
<FormDescription>
|
||||
Automatically will have SSL Certificate.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={form.formState.isSubmitting}
|
||||
form="hook-form"
|
||||
type="submit"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
66
components/dashboard/application/domains/delete-domain.tsx
Normal file
66
components/dashboard/application/domains/delete-domain.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
domainId: string;
|
||||
}
|
||||
export const DeleteDomain = ({ domainId }: Props) => {
|
||||
const { mutateAsync, isLoading } = api.domain.delete.useMutation();
|
||||
const utils = api.useUtils();
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" isLoading={isLoading}>
|
||||
<TrashIcon className="size-4 " />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
domain
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
domainId,
|
||||
})
|
||||
.then((data) => {
|
||||
utils.domain.byApplicationId.invalidate({
|
||||
applicationId: data?.applicationId,
|
||||
});
|
||||
utils.application.readTraefikConfig.invalidate({
|
||||
applicationId: data?.applicationId,
|
||||
});
|
||||
toast.success("Domain delete succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to delete Domain");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
89
components/dashboard/application/domains/show-domains.tsx
Normal file
89
components/dashboard/application/domains/show-domains.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { ExternalLink, GlobeIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { DeleteDomain } from "./delete-domain";
|
||||
import Link from "next/link";
|
||||
import { AddDomain } from "./add-domain";
|
||||
import { UpdateDomain } from "./update-domain";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const ShowDomains = ({ applicationId }: Props) => {
|
||||
const { data } = api.domain.byApplicationId.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{
|
||||
enabled: !!applicationId,
|
||||
},
|
||||
);
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle className="text-xl">Domains</CardTitle>
|
||||
<CardDescription>
|
||||
Domains are used to access to the application
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{data && data?.length > 0 && (
|
||||
<AddDomain applicationId={applicationId} />
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-row gap-4">
|
||||
{data?.length === 0 ? (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3">
|
||||
<GlobeIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
To access to the application is required to set at least 1
|
||||
domain
|
||||
</span>
|
||||
<AddDomain applicationId={applicationId}>Add Domain</AddDomain>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{data?.map((item) => {
|
||||
return (
|
||||
<div
|
||||
key={item.domainId}
|
||||
className="flex w-full items-center gap-4 max-sm:flex-wrap border p-4 rounded-lg"
|
||||
>
|
||||
<Link target="_blank" href={`http://${item.host}`}>
|
||||
<ExternalLink className="size-5" />
|
||||
</Link>
|
||||
|
||||
<Input disabled value={item.host} />
|
||||
<Button variant="outline" disabled>
|
||||
{item.path}
|
||||
</Button>
|
||||
<Button variant="outline" disabled>
|
||||
{item.port}
|
||||
</Button>
|
||||
<Button variant="outline" disabled>
|
||||
{item.https ? "HTTPS" : "HTTP"}
|
||||
</Button>
|
||||
<UpdateDomain domainId={item.domainId} />
|
||||
<DeleteDomain domainId={item.domainId} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
260
components/dashboard/application/domains/update-domain.tsx
Normal file
260
components/dashboard/application/domains/update-domain.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AlertTriangle, PenBoxIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const hostnameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\.-]*\.[a-zA-Z]{2,}$/;
|
||||
|
||||
const updateDomain = z.object({
|
||||
host: z.string().regex(hostnameRegex, { message: "Invalid hostname" }),
|
||||
path: z.string().min(1),
|
||||
port: z
|
||||
.number()
|
||||
.min(1, { message: "Port must be at least 1" })
|
||||
.max(65535, { message: "Port must be 65535 or below" }),
|
||||
https: z.boolean(),
|
||||
certificateType: z.enum(["letsencrypt", "none"]),
|
||||
});
|
||||
|
||||
type UpdateDomain = z.infer<typeof updateDomain>;
|
||||
|
||||
interface Props {
|
||||
domainId: string;
|
||||
}
|
||||
|
||||
export const UpdateDomain = ({ domainId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { data, refetch } = api.domain.one.useQuery(
|
||||
{
|
||||
domainId,
|
||||
},
|
||||
{
|
||||
enabled: !!domainId,
|
||||
},
|
||||
);
|
||||
const { mutateAsync, isError, error } = api.domain.update.useMutation();
|
||||
|
||||
const form = useForm<UpdateDomain>({
|
||||
defaultValues: {
|
||||
host: "",
|
||||
https: true,
|
||||
path: "/",
|
||||
port: 3000,
|
||||
certificateType: "none",
|
||||
},
|
||||
resolver: zodResolver(updateDomain),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
host: data.host || "",
|
||||
port: data.port || 3000,
|
||||
path: data.path || "/",
|
||||
https: data.https,
|
||||
certificateType: data.certificateType,
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const onSubmit = async (data: UpdateDomain) => {
|
||||
await mutateAsync({
|
||||
domainId,
|
||||
host: data.host,
|
||||
https: data.https,
|
||||
path: data.path,
|
||||
port: data.port,
|
||||
certificateType: data.certificateType,
|
||||
})
|
||||
.then(async (data) => {
|
||||
toast.success("Domain Updated");
|
||||
await refetch();
|
||||
await utils.domain.byApplicationId.invalidate({
|
||||
applicationId: data?.applicationId,
|
||||
});
|
||||
await utils.application.readTraefikConfig.invalidate({
|
||||
applicationId: data?.applicationId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to update the domain");
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger className="" asChild>
|
||||
<Button>
|
||||
<PenBoxIcon className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Domain</DialogTitle>
|
||||
<DialogDescription>
|
||||
In this section you can add custom domains
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && (
|
||||
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
|
||||
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
{error?.message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-8 "
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="api.dokploy.com" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="path"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"/"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Container Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"3000"}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(Number.parseInt(e.target.value));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="certificateType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>Certificate</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a certificate" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={"none"}>None</SelectItem>
|
||||
<SelectItem value={"letsencrypt"}>
|
||||
Letsencrypt
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="https"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>HTTPS</FormLabel>
|
||||
<FormDescription>
|
||||
Automatically will have SSL Certificate.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={form.formState.isSubmitting}
|
||||
form="hook-form"
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
120
components/dashboard/application/enviroment/show.tsx
Normal file
120
components/dashboard/application/enviroment/show.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { api } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const addEnviromentSchema = z.object({
|
||||
enviroment: z.string(),
|
||||
});
|
||||
|
||||
type EnviromentSchema = z.infer<typeof addEnviromentSchema>;
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const ShowEnviroment = ({ applicationId }: Props) => {
|
||||
const { mutateAsync, isLoading } =
|
||||
api.application.saveEnviroment.useMutation();
|
||||
|
||||
const { data, refetch } = api.application.one.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{
|
||||
enabled: !!applicationId,
|
||||
},
|
||||
);
|
||||
const form = useForm<EnviromentSchema>({
|
||||
defaultValues: {
|
||||
enviroment: "",
|
||||
},
|
||||
resolver: zodResolver(addEnviromentSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
enviroment: data.env || "",
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
|
||||
const onSubmit = async (data: EnviromentSchema) => {
|
||||
mutateAsync({
|
||||
env: data.enviroment,
|
||||
applicationId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Enviroments Added");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to add enviroment");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Enviroment Settings</CardTitle>
|
||||
<CardDescription>
|
||||
You can add enviroment variables to your resource.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4 "
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enviroment"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="NODE_ENV=production"
|
||||
className="h-96"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-end">
|
||||
<Button isLoading={isLoading} className="w-fit" type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const DeployApplication = ({ applicationId }: Props) => {
|
||||
const { data, refetch } = api.application.one.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
|
||||
const { mutateAsync: markRunning } =
|
||||
api.application.markRunning.useMutation();
|
||||
const { mutateAsync: deploy } = api.application.deploy.useMutation();
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button isLoading={data?.applicationStatus === "running"}>
|
||||
Deploy
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will deploy the application
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await markRunning({
|
||||
applicationId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Application Deploying....");
|
||||
|
||||
await refetch();
|
||||
await deploy({
|
||||
applicationId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Application Deployed Succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to deploy Application");
|
||||
});
|
||||
|
||||
await refetch();
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error(e.message || "Error to deploy Application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { useEffect } from "react";
|
||||
import { api } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const DockerProviderSchema = z.object({
|
||||
dockerImage: z.string().min(1, {
|
||||
message: "Docker image is required",
|
||||
}),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
});
|
||||
|
||||
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const SaveDockerProvider = ({ applicationId }: Props) => {
|
||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||
|
||||
const { mutateAsync } = api.application.saveDockerProvider.useMutation();
|
||||
|
||||
const form = useForm<DockerProvider>({
|
||||
defaultValues: {
|
||||
dockerImage: "",
|
||||
password: "",
|
||||
username: "",
|
||||
},
|
||||
resolver: zodResolver(DockerProviderSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
dockerImage: data.dockerImage || "",
|
||||
password: data.password || "",
|
||||
username: data.username || "",
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
|
||||
const onSubmit = async (values: DockerProvider) => {
|
||||
await mutateAsync({
|
||||
dockerImage: values.dockerImage,
|
||||
password: values.password || null,
|
||||
applicationId,
|
||||
username: values.username || null,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Docker Provider Saved");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to save the Docker provider");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid md:grid-cols-2 gap-4 ">
|
||||
<div className="md:col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dockerImage"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Docker Image</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="node:16" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="username" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-fit"
|
||||
isLoading={form.formState.isSubmitting}
|
||||
>
|
||||
Save{" "}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,254 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CopyIcon, LockIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const GitProviderSchema = z.object({
|
||||
repositoryURL: z.string().min(1, {
|
||||
message: "Repository URL is required",
|
||||
}),
|
||||
branch: z.string().min(1, "Branch required"),
|
||||
buildPath: z.string().min(1, "Build Path required"),
|
||||
});
|
||||
|
||||
type GitProvider = z.infer<typeof GitProviderSchema>;
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||
|
||||
const { mutateAsync, isLoading } =
|
||||
api.application.saveGitProdiver.useMutation();
|
||||
const { mutateAsync: generateSSHKey, isLoading: isGeneratingSSHKey } =
|
||||
api.application.generateSSHKey.useMutation();
|
||||
|
||||
const { mutateAsync: removeSSHKey, isLoading: isRemovingSSHKey } =
|
||||
api.application.removeSSHKey.useMutation();
|
||||
const form = useForm<GitProvider>({
|
||||
defaultValues: {
|
||||
branch: "",
|
||||
buildPath: "/",
|
||||
repositoryURL: "",
|
||||
},
|
||||
resolver: zodResolver(GitProviderSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
branch: data.customGitBranch || "",
|
||||
buildPath: data.customGitBuildPath || "/",
|
||||
repositoryURL: data.customGitUrl || "",
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
|
||||
const onSubmit = async (values: GitProvider) => {
|
||||
await mutateAsync({
|
||||
customGitBranch: values.branch,
|
||||
customGitBuildPath: values.buildPath,
|
||||
customGitUrl: values.repositoryURL,
|
||||
applicationId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Git Provider Saved");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to save the Git provider");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid md:grid-cols-2 gap-4 ">
|
||||
<div className="md:col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="repositoryURL"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row justify-between">
|
||||
Repository URL
|
||||
<div className="flex gap-2">
|
||||
<Dialog>
|
||||
<DialogTrigger className="flex flex-row gap-2">
|
||||
<LockIcon className="size-4 text-muted-foreground" />?
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Private Repository</DialogTitle>
|
||||
<DialogDescription>
|
||||
If your repository is private is necessary to
|
||||
generate SSH Keys to add to your git provider.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
placeholder="Please click on Generate SSH Key"
|
||||
className="no-scrollbar h-64 text-muted-foreground"
|
||||
disabled={!data?.customGitSSHKey}
|
||||
contentEditable={false}
|
||||
value={
|
||||
data?.customGitSSHKey ||
|
||||
"Please click on Generate SSH Key"
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-2"
|
||||
onClick={() => {
|
||||
copy(
|
||||
data?.customGitSSHKey ||
|
||||
"Generate a SSH Key",
|
||||
);
|
||||
toast.success("SSH Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="flex sm:justify-between gap-3.5 flex-col sm:flex-col w-full">
|
||||
<div className="flex flex-row gap-2 w-full justify-between flex-wrap">
|
||||
{data?.customGitSSHKey && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={
|
||||
isGeneratingSSHKey || isRemovingSSHKey
|
||||
}
|
||||
className="max-sm:w-full"
|
||||
onClick={async () => {
|
||||
await removeSSHKey({
|
||||
applicationId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("SSH Key Removed");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error to remove the SSH Key",
|
||||
);
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Remove SSH Key
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
isLoading={
|
||||
isGeneratingSSHKey || isRemovingSSHKey
|
||||
}
|
||||
className="max-sm:w-full"
|
||||
onClick={async () => {
|
||||
await generateSSHKey({
|
||||
applicationId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("SSH Key Generated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error to generate the SSH Key",
|
||||
);
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Generate SSH Key
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Is recommended to remove the SSH Key if you want
|
||||
to deploy a public repository.
|
||||
</span>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="git@bitbucket.org" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="branch"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Branch</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Branch" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="buildPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Build Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end">
|
||||
<Button type="submit" className="w-fit" isLoading={isLoading}>
|
||||
Save{" "}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,307 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const GithubProviderSchema = z.object({
|
||||
buildPath: z.string().min(1, "Path is required").default("/"),
|
||||
repository: z
|
||||
.object({
|
||||
repo: z.string().min(1, "Repo is required"),
|
||||
owner: z.string().min(1, "Owner is required"),
|
||||
})
|
||||
.required(),
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
});
|
||||
|
||||
type GithubProvider = z.infer<typeof GithubProviderSchema>;
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||
|
||||
const { mutateAsync, isLoading: isSavingGithubProvider } =
|
||||
api.application.saveGithubProvider.useMutation();
|
||||
|
||||
const form = useForm<GithubProvider>({
|
||||
defaultValues: {
|
||||
buildPath: "/",
|
||||
repository: {
|
||||
owner: "",
|
||||
repo: "",
|
||||
},
|
||||
branch: "",
|
||||
},
|
||||
resolver: zodResolver(GithubProviderSchema),
|
||||
});
|
||||
|
||||
const repository = form.watch("repository");
|
||||
|
||||
const { data: repositories, isLoading: isLoadingRepositories } =
|
||||
api.admin.getRepositories.useQuery();
|
||||
|
||||
const {
|
||||
data: branches,
|
||||
fetchStatus,
|
||||
status,
|
||||
} = api.admin.getBranches.useQuery(
|
||||
{
|
||||
owner: repository?.owner,
|
||||
repo: repository?.repo,
|
||||
},
|
||||
{ enabled: !!repository?.owner && !!repository?.repo },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
branch: data.branch || "",
|
||||
repository: {
|
||||
repo: data.repository || "",
|
||||
owner: data.owner || "",
|
||||
},
|
||||
buildPath: data.buildPath || "/",
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
|
||||
const onSubmit = async (data: GithubProvider) => {
|
||||
await mutateAsync({
|
||||
branch: data.branch,
|
||||
repository: data.repository.repo,
|
||||
applicationId,
|
||||
owner: data.repository.owner,
|
||||
buildPath: data.buildPath,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to save the github provider");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4 py-3"
|
||||
>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="repository"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2 flex flex-col">
|
||||
<FormLabel>Repository</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between !bg-input",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
{repositories?.map((repo) => (
|
||||
<CommandItem
|
||||
value={repo.url}
|
||||
key={repo.url}
|
||||
onSelect={() => {
|
||||
form.setValue("repository", {
|
||||
owner: repo.owner.login as string,
|
||||
repo: repo.name,
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
>
|
||||
{repo.name}
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
repo.name === field.value.repo
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{form.formState.errors.repository && (
|
||||
<p className={cn("text-sm font-medium text-destructive")}>
|
||||
Repository is required
|
||||
</p>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="branch"
|
||||
render={({ field }) => (
|
||||
<FormItem className="block w-full">
|
||||
<FormLabel>Branch</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
" w-full justify-between !bg-input",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status === "loading" && fetchStatus === "fetching"
|
||||
? "Loading...."
|
||||
: field.value
|
||||
? branches?.find(
|
||||
(branch) => branch.name === field.value,
|
||||
)?.name
|
||||
: "Select branch"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search branch..."
|
||||
className="h-9"
|
||||
/>
|
||||
{status === "loading" && fetchStatus === "fetching" && (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Loading Branches....
|
||||
</span>
|
||||
)}
|
||||
{!repository?.owner && (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a repository
|
||||
</span>
|
||||
)}
|
||||
<ScrollArea className="h-96">
|
||||
<CommandEmpty>No branch found.</CommandEmpty>
|
||||
|
||||
<CommandGroup>
|
||||
{branches?.map((branch) => (
|
||||
<CommandItem
|
||||
value={branch.name}
|
||||
key={branch.commit.sha}
|
||||
onSelect={() => {
|
||||
form.setValue("branch", branch.name);
|
||||
}}
|
||||
>
|
||||
{branch.name}
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
branch.name === field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
|
||||
<FormMessage />
|
||||
</Popover>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="buildPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Build Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
isLoading={isSavingGithubProvider}
|
||||
type="submit"
|
||||
className="w-fit"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
96
components/dashboard/application/general/generic/show.tsx
Normal file
96
components/dashboard/application/general/generic/show.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { SaveDockerProvider } from "@/components/dashboard/application/general/generic/save-docker-provider";
|
||||
import { SaveGitProvider } from "@/components/dashboard/application/general/generic/save-git-provider";
|
||||
import { SaveGithubProvider } from "@/components/dashboard/application/general/generic/save-github-provider";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/utils/api";
|
||||
import { GitBranch, LockIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
type TabState = "github" | "docker" | "git";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const ShowProviderForm = ({ applicationId }: Props) => {
|
||||
const { data: haveGithubConfigured } =
|
||||
api.admin.haveGithubConfigured.useQuery();
|
||||
|
||||
const { data: application } = api.application.one.useQuery({ applicationId });
|
||||
const [tab, setSab] = useState<TabState>(application?.sourceType || "github");
|
||||
return (
|
||||
<Card className="group relative w-full bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-start justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="flex flex-col space-y-0.5">Provider</span>
|
||||
<p className="flex items-center text-sm font-normal text-muted-foreground">
|
||||
Select the source of your code
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden space-y-1 text-sm font-normal md:block">
|
||||
<GitBranch className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs
|
||||
value={tab}
|
||||
className="w-full"
|
||||
onValueChange={(e) => {
|
||||
setSab(e as TabState);
|
||||
}}
|
||||
>
|
||||
<TabsList className="grid w-fit grid-cols-4 bg-transparent">
|
||||
<TabsTrigger
|
||||
value="github"
|
||||
className="rounded-none border-b-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||
>
|
||||
Github
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="docker"
|
||||
className="rounded-none border-b-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||
>
|
||||
Docker
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="git"
|
||||
className="rounded-none border-b-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||
>
|
||||
Git
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="github" className="w-full p-2">
|
||||
{haveGithubConfigured ? (
|
||||
<SaveGithubProvider applicationId={applicationId} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<LockIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
To deploy using GitHub, you need to configure your account
|
||||
first. Please, go to{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-foreground"
|
||||
>
|
||||
Settings
|
||||
</Link>{" "}
|
||||
to do so.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="docker" className="w-full p-2">
|
||||
<SaveDockerProvider applicationId={applicationId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="git" className="w-full p-2">
|
||||
<SaveGitProvider applicationId={applicationId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
appName: string;
|
||||
}
|
||||
|
||||
export const ResetApplication = ({ applicationId, appName }: Props) => {
|
||||
const { refetch } = api.application.one.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
const { mutateAsync: reload, isLoading } =
|
||||
api.application.reload.useMutation();
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="secondary" isLoading={isLoading}>
|
||||
Reload
|
||||
<RefreshCcw className="size-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will reload the application
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
applicationId,
|
||||
appName,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Service Reloaded");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to reload the service");
|
||||
});
|
||||
await refetch();
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
79
components/dashboard/application/general/show.tsx
Normal file
79
components/dashboard/application/general/show.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { ShowBuildChooseForm } from "@/components/dashboard/application/build/show";
|
||||
import { ShowProviderForm } from "@/components/dashboard/application/general/generic/show";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import { api } from "@/utils/api";
|
||||
import React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { RedbuildApplication } from "../rebuild-application";
|
||||
import { StartApplication } from "../start-application";
|
||||
import { StopApplication } from "../stop-application";
|
||||
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
|
||||
import { Terminal } from "lucide-react";
|
||||
import { DeployApplication } from "./deploy-application";
|
||||
import { ResetApplication } from "./reset-application";
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
const { data, refetch } = api.application.one.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
const { mutateAsync: update } = api.application.update.useMutation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Deploy Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||
<DeployApplication applicationId={applicationId} />
|
||||
<ResetApplication
|
||||
applicationId={applicationId}
|
||||
appName={data?.appName || ""}
|
||||
/>
|
||||
|
||||
<Toggle
|
||||
aria-label="Toggle italic"
|
||||
pressed={data?.autoDeploy || false}
|
||||
onPressedChange={async (enabled) => {
|
||||
await update({
|
||||
applicationId,
|
||||
autoDeploy: enabled,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Auto Deploy Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to update Auto Deploy");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Autodeploy
|
||||
</Toggle>
|
||||
<RedbuildApplication applicationId={applicationId} />
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
<StartApplication applicationId={applicationId} />
|
||||
) : (
|
||||
<StopApplication applicationId={applicationId} />
|
||||
)}
|
||||
<DockerTerminalModal appName={data?.appName || ""}>
|
||||
<Button variant="outline">
|
||||
<Terminal />
|
||||
Open Terminal
|
||||
</Button>
|
||||
</DockerTerminalModal>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ShowProviderForm applicationId={applicationId} />
|
||||
<ShowBuildChooseForm applicationId={applicationId} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
88
components/dashboard/application/logs/show.tsx
Normal file
88
components/dashboard/application/logs/show.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
export const DockerLogs = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/docker/logs/docker-logs-id").then(
|
||||
(e) => e.DockerLogsId,
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
interface Props {
|
||||
appName: string;
|
||||
}
|
||||
|
||||
export const ShowDockerLogs = ({ appName }: Props) => {
|
||||
const { data } = api.docker.getContainersByAppNameMatch.useQuery(
|
||||
{
|
||||
appName,
|
||||
},
|
||||
{
|
||||
enabled: !!appName,
|
||||
},
|
||||
);
|
||||
const [containerId, setContainerId] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (data && data?.length > 0) {
|
||||
setContainerId(data[0]?.containerId);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Logs</CardTitle>
|
||||
<CardDescription>
|
||||
Watch the logs of the application in real time
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<Label>Select a container to view logs</Label>
|
||||
<Select onValueChange={setContainerId} value={containerId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a container" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{data?.map((container) => (
|
||||
<SelectItem
|
||||
key={container.containerId}
|
||||
value={container.containerId}
|
||||
>
|
||||
{container.name} ({container.containerId}) {container.state}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>Containers ({data?.length})</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<DockerLogs
|
||||
id="terminal"
|
||||
containerId={containerId || "select-a-container"}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
85
components/dashboard/application/rebuild-application.tsx
Normal file
85
components/dashboard/application/rebuild-application.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { Hammer } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const RedbuildApplication = ({ applicationId }: Props) => {
|
||||
const { data } = api.application.one.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
const { mutateAsync: markRunning } =
|
||||
api.application.markRunning.useMutation();
|
||||
const { mutateAsync } = api.application.redeploy.useMutation();
|
||||
const utils = api.useUtils();
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
>
|
||||
Rebuild
|
||||
<Hammer className="size-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure to rebuild the application?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Is required to deploy at least 1 time in order to reuse the same
|
||||
code
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await markRunning({
|
||||
applicationId,
|
||||
})
|
||||
.then(async () => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.application.one.invalidate({
|
||||
applicationId,
|
||||
});
|
||||
toast.success("Application rebuild succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to rebuild the application");
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to rebuild the application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
65
components/dashboard/application/start-application.tsx
Normal file
65
components/dashboard/application/start-application.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const StartApplication = ({ applicationId }: Props) => {
|
||||
const { mutateAsync, isLoading } = api.application.start.useMutation();
|
||||
const utils = api.useUtils();
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="secondary" isLoading={isLoading}>
|
||||
Start
|
||||
<CheckCircle2 className="size-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure to start the application?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will start the application
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.application.one.invalidate({
|
||||
applicationId,
|
||||
});
|
||||
toast.success("Application started succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to start the Application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
65
components/dashboard/application/stop-application.tsx
Normal file
65
components/dashboard/application/stop-application.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { Ban } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const StopApplication = ({ applicationId }: Props) => {
|
||||
const { mutateAsync, isLoading } = api.application.stop.useMutation();
|
||||
const utils = api.useUtils();
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" isLoading={isLoading}>
|
||||
Stop
|
||||
<Ban className="size-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you absolutely sure to stop the application?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will stop the application
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.application.one.invalidate({
|
||||
applicationId,
|
||||
});
|
||||
toast.success("Application stopped succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to stop the Application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
165
components/dashboard/application/update-application.tsx
Normal file
165
components/dashboard/application/update-application.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AlertTriangle, SquarePen } from "lucide-react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const updateApplicationSchema = z.object({
|
||||
name: z.string().min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
type UpdateApplication = z.infer<typeof updateApplicationSchema>;
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const UpdateApplication = ({ applicationId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, error, isError, isLoading } =
|
||||
api.application.update.useMutation();
|
||||
const { data } = api.application.one.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{
|
||||
enabled: !!applicationId,
|
||||
},
|
||||
);
|
||||
const form = useForm<UpdateApplication>({
|
||||
defaultValues: {
|
||||
description: data?.description ?? "",
|
||||
name: data?.name ?? "",
|
||||
},
|
||||
resolver: zodResolver(updateApplicationSchema),
|
||||
});
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
description: data.description ?? "",
|
||||
name: data.name,
|
||||
});
|
||||
}
|
||||
}, [data, form, form.reset]);
|
||||
|
||||
const onSubmit = async (formData: UpdateApplication) => {
|
||||
await mutateAsync({
|
||||
name: formData.name,
|
||||
applicationId: applicationId,
|
||||
description: formData.description || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Application updated succesfully");
|
||||
utils.application.one.invalidate({
|
||||
applicationId: applicationId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to update the application");
|
||||
})
|
||||
.finally(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost">
|
||||
<SquarePen className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modify Application</DialogTitle>
|
||||
<DialogDescription>Update the application data</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && (
|
||||
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
|
||||
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
{error.message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="grid items-center gap-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="hook-form-update-application"
|
||||
className="grid w-full gap-4 "
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Tesla" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Description about your project..."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-update-application"
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user