Merge branch 'canary' into hehehai/feat-server-custom-name

This commit is contained in:
Mauricio Siu
2024-06-08 14:37:51 -06:00
295 changed files with 43807 additions and 3328 deletions

View File

@@ -61,7 +61,7 @@ export const Login2FA = ({ authId }: Props) => {
id: authId,
})
.then(() => {
toast.success("Signin succesfully", {
toast.success("Signin successfully", {
duration: 2000,
});

View File

@@ -0,0 +1,764 @@
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 { api } from "@/utils/api";
import { AlertBlock } from "@/components/shared/alert-block";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { HelpCircle, Settings } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CodeEditor } from "@/components/shared/code-editor";
const HealthCheckSwarmSchema = z
.object({
Test: z.array(z.string()).optional(),
Interval: z.number().optional(),
Timeout: z.number().optional(),
StartPeriod: z.number().optional(),
Retries: z.number().optional(),
})
.strict();
const RestartPolicySwarmSchema = z
.object({
Condition: z.string().optional(),
Delay: z.number().optional(),
MaxAttempts: z.number().optional(),
Window: z.number().optional(),
})
.strict();
const PreferenceSchema = z
.object({
Spread: z.object({
SpreadDescriptor: z.string(),
}),
})
.strict();
const PlatformSchema = z
.object({
Architecture: z.string(),
OS: z.string(),
})
.strict();
const PlacementSwarmSchema = z
.object({
Constraints: z.array(z.string()).optional(),
Preferences: z.array(PreferenceSchema).optional(),
MaxReplicas: z.number().optional(),
Platforms: z.array(PlatformSchema).optional(),
})
.strict();
const UpdateConfigSwarmSchema = z
.object({
Parallelism: z.number(),
Delay: z.number().optional(),
FailureAction: z.string().optional(),
Monitor: z.number().optional(),
MaxFailureRatio: z.number().optional(),
Order: z.string(),
})
.strict();
const ReplicatedSchema = z
.object({
Replicas: z.number().optional(),
})
.strict();
const ReplicatedJobSchema = z
.object({
MaxConcurrent: z.number().optional(),
TotalCompletions: z.number().optional(),
})
.strict();
const ServiceModeSwarmSchema = z
.object({
Replicated: ReplicatedSchema.optional(),
Global: z.object({}).optional(),
ReplicatedJob: ReplicatedJobSchema.optional(),
GlobalJob: z.object({}).optional(),
})
.strict();
const NetworkSwarmSchema = z.array(
z
.object({
Target: z.string().optional(),
Aliases: z.array(z.string()).optional(),
DriverOpts: z.object({}).optional(),
})
.strict(),
);
const LabelsSwarmSchema = z.record(z.string());
const createStringToJSONSchema = (schema: z.ZodTypeAny) => {
return z
.string()
.transform((str, ctx) => {
if (str === null || str === "") {
return null;
}
try {
return JSON.parse(str);
} catch (e) {
ctx.addIssue({ code: "custom", message: "Invalid JSON format" });
return z.NEVER;
}
})
.superRefine((data, ctx) => {
if (data === null) {
return;
}
if (Object.keys(data).length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Object cannot be empty",
});
return;
}
const parseResult = schema.safeParse(data);
if (!parseResult.success) {
for (const error of parseResult.error.issues) {
const path = error.path.join(".");
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `${path} ${error.message}`,
});
}
}
});
};
const addSwarmSettings = z.object({
healthCheckSwarm: createStringToJSONSchema(HealthCheckSwarmSchema).nullable(),
restartPolicySwarm: createStringToJSONSchema(
RestartPolicySwarmSchema,
).nullable(),
placementSwarm: createStringToJSONSchema(PlacementSwarmSchema).nullable(),
updateConfigSwarm: createStringToJSONSchema(
UpdateConfigSwarmSchema,
).nullable(),
rollbackConfigSwarm: createStringToJSONSchema(
UpdateConfigSwarmSchema,
).nullable(),
modeSwarm: createStringToJSONSchema(ServiceModeSwarmSchema).nullable(),
labelsSwarm: createStringToJSONSchema(LabelsSwarmSchema).nullable(),
networkSwarm: createStringToJSONSchema(NetworkSwarmSchema).nullable(),
});
type AddSwarmSettings = z.infer<typeof addSwarmSettings>;
interface Props {
applicationId: string;
}
export const AddSwarmSettings = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery(
{
applicationId,
},
{
enabled: !!applicationId,
},
);
const { mutateAsync, isError, error, isLoading } =
api.application.update.useMutation();
const form = useForm<AddSwarmSettings>({
defaultValues: {
healthCheckSwarm: null,
restartPolicySwarm: null,
placementSwarm: null,
updateConfigSwarm: null,
rollbackConfigSwarm: null,
modeSwarm: null,
labelsSwarm: null,
networkSwarm: null,
},
resolver: zodResolver(addSwarmSettings),
});
useEffect(() => {
if (data) {
form.reset({
healthCheckSwarm: data.healthCheckSwarm
? JSON.stringify(data.healthCheckSwarm, null, 2)
: null,
restartPolicySwarm: data.restartPolicySwarm
? JSON.stringify(data.restartPolicySwarm, null, 2)
: null,
placementSwarm: data.placementSwarm
? JSON.stringify(data.placementSwarm, null, 2)
: null,
updateConfigSwarm: data.updateConfigSwarm
? JSON.stringify(data.updateConfigSwarm, null, 2)
: null,
rollbackConfigSwarm: data.rollbackConfigSwarm
? JSON.stringify(data.rollbackConfigSwarm, null, 2)
: null,
modeSwarm: data.modeSwarm
? JSON.stringify(data.modeSwarm, null, 2)
: null,
labelsSwarm: data.labelsSwarm
? JSON.stringify(data.labelsSwarm, null, 2)
: null,
networkSwarm: data.networkSwarm
? JSON.stringify(data.networkSwarm, null, 2)
: null,
});
}
}, [form, form.reset, data]);
const onSubmit = async (data: AddSwarmSettings) => {
await mutateAsync({
applicationId,
healthCheckSwarm: data.healthCheckSwarm,
restartPolicySwarm: data.restartPolicySwarm,
placementSwarm: data.placementSwarm,
updateConfigSwarm: data.updateConfigSwarm,
rollbackConfigSwarm: data.rollbackConfigSwarm,
modeSwarm: data.modeSwarm,
labelsSwarm: data.labelsSwarm,
networkSwarm: data.networkSwarm,
})
.then(async () => {
toast.success("Swarm settings updated");
refetch();
})
.catch(() => {
toast.error("Error to update the swarm settings");
});
};
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="secondary" className="cursor-pointer w-fit">
<Settings className="size-4 text-muted-foreground" />
Swarm Settings
</Button>
</DialogTrigger>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-5xl p-0">
<DialogHeader className="p-6">
<DialogTitle>Swarm Settings</DialogTitle>
<DialogDescription>
Update certain settings using a json object.
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-add-permissions"
onSubmit={form.handleSubmit(onSubmit)}
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4 relative"
>
<FormField
control={form.control}
name="healthCheckSwarm"
render={({ field }) => (
<FormItem className="relative max-lg:px-4 lg:pl-6 ">
<FormLabel>Health Check</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
Check the interface
<HelpCircle className="size-4 text-muted-foreground" />
</FormDescription>
</TooltipTrigger>
<TooltipContent
className="w-full z-[999]"
align="start"
side="bottom"
>
<code>
<pre>
{`{
Test?: string[] | undefined;
Interval?: number | undefined;
Timeout?: number | undefined;
StartPeriod?: number | undefined;
Retries?: number | undefined;
}`}
</pre>
</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<FormControl>
<CodeEditor
language="json"
placeholder={`{
"Test" : ["CMD-SHELL", "curl -f http://localhost:3000/health"],
"Interval" : 10000,
"Timeout" : 10000,
"StartPeriod" : 10000,
"Retries" : 10
}`}
className="h-[12rem] font-mono"
{...field}
value={field?.value || ""}
/>
</FormControl>
<pre>
<FormMessage />
</pre>
</FormItem>
)}
/>
<FormField
control={form.control}
name="restartPolicySwarm"
render={({ field }) => (
<FormItem className="relative max-lg:px-4 lg:pr-6 ">
<FormLabel>Restart Policy</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
Check the interface
<HelpCircle className="size-4 text-muted-foreground" />
</FormDescription>
</TooltipTrigger>
<TooltipContent
className="w-full z-[999]"
align="start"
side="bottom"
>
<code>
<pre>
{`{
Condition?: string | undefined;
Delay?: number | undefined;
MaxAttempts?: number | undefined;
Window?: number | undefined;
}`}
</pre>
</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<FormControl>
<CodeEditor
language="json"
placeholder={`{
"Condition" : "on-failure",
"Delay" : 10000,
"MaxAttempts" : 10,
"Window" : 10000
} `}
className="h-[12rem] font-mono"
{...field}
value={field?.value || ""}
/>
</FormControl>
<pre>
<FormMessage />
</pre>
</FormItem>
)}
/>
<FormField
control={form.control}
name="placementSwarm"
render={({ field }) => (
<FormItem className="relative max-lg:px-4 lg:pl-6 ">
<FormLabel>Placement</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
Check the interface
<HelpCircle className="size-4 text-muted-foreground" />
</FormDescription>
</TooltipTrigger>
<TooltipContent
className="w-full z-[999]"
align="start"
side="bottom"
>
<code>
<pre>
{`{
Constraints?: string[] | undefined;
Preferences?: Array<{ Spread: { SpreadDescriptor: string } }> | undefined;
MaxReplicas?: number | undefined;
Platforms?:
| Array<{
Architecture: string;
OS: string;
}>
| undefined;
}`}
</pre>
</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<FormControl>
<CodeEditor
language="json"
placeholder={`{
"Constraints" : ["node.role==manager"],
"Preferences" : [{
"Spread" : {
"SpreadDescriptor" : "node.labels.region"
}
}],
"MaxReplicas" : 10,
"Platforms" : [{
"Architecture" : "amd64",
"OS" : "linux"
}]
} `}
className="h-[21rem] font-mono"
{...field}
value={field?.value || ""}
/>
</FormControl>
<pre>
<FormMessage />
</pre>
</FormItem>
)}
/>
<FormField
control={form.control}
name="updateConfigSwarm"
render={({ field }) => (
<FormItem className="relative max-lg:px-4 lg:pr-6 ">
<FormLabel>Update Config</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
Check the interface
<HelpCircle className="size-4 text-muted-foreground" />
</FormDescription>
</TooltipTrigger>
<TooltipContent
className="w-full z-[999]"
align="start"
side="bottom"
>
<code>
<pre>
{`{
Parallelism?: number;
Delay?: number | undefined;
FailureAction?: string | undefined;
Monitor?: number | undefined;
MaxFailureRatio?: number | undefined;
Order: string;
}`}
</pre>
</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<FormControl>
<CodeEditor
language="json"
placeholder={`{
"Parallelism" : 1,
"Delay" : 10000,
"FailureAction" : "continue",
"Monitor" : 10000,
"MaxFailureRatio" : 10,
"Order" : "start-first"
}`}
className="h-[21rem] font-mono"
{...field}
value={field?.value || ""}
/>
</FormControl>
<pre>
<FormMessage />
</pre>
</FormItem>
)}
/>
<FormField
control={form.control}
name="rollbackConfigSwarm"
render={({ field }) => (
<FormItem className="relative max-lg:px-4 lg:pl-6 ">
<FormLabel>Rollback Config</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
Check the interface
<HelpCircle className="size-4 text-muted-foreground" />
</FormDescription>
</TooltipTrigger>
<TooltipContent
className="w-full z-[999]"
align="start"
side="bottom"
>
<code>
<pre>
{`{
Parallelism?: number;
Delay?: number | undefined;
FailureAction?: string | undefined;
Monitor?: number | undefined;
MaxFailureRatio?: number | undefined;
Order: string;
}`}
</pre>
</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<FormControl>
<CodeEditor
language="json"
placeholder={`{
"Parallelism" : 1,
"Delay" : 10000,
"FailureAction" : "continue",
"Monitor" : 10000,
"MaxFailureRatio" : 10,
"Order" : "start-first"
}`}
className="h-[17rem] font-mono"
{...field}
value={field?.value || ""}
/>
</FormControl>
<pre>
<FormMessage />
</pre>
</FormItem>
)}
/>
<FormField
control={form.control}
name="modeSwarm"
render={({ field }) => (
<FormItem className="relative max-lg:px-4 lg:pr-6 ">
<FormLabel>Mode</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
Check the interface
<HelpCircle className="size-4 text-muted-foreground" />
</FormDescription>
</TooltipTrigger>
<TooltipContent
className="w-full z-[999]"
align="center"
side="bottom"
>
<code>
<pre>
{`{
Replicated?: { Replicas?: number | undefined } | undefined;
Global?: {} | undefined;
ReplicatedJob?:
| {
MaxConcurrent?: number | undefined;
TotalCompletions?: number | undefined;
}
| undefined;
GlobalJob?: {} | undefined;
}`}
</pre>
</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<FormControl>
<CodeEditor
language="json"
placeholder={`{
"Replicated" : {
"Replicas" : 1
},
"Global" : {},
"ReplicatedJob" : {
"MaxConcurrent" : 1,
"TotalCompletions" : 1
},
"GlobalJob" : {}
}`}
className="h-[17rem] font-mono"
{...field}
value={field?.value || ""}
/>
</FormControl>
<pre>
<FormMessage />
</pre>
</FormItem>
)}
/>
<FormField
control={form.control}
name="networkSwarm"
render={({ field }) => (
<FormItem className="relative max-lg:px-4 lg:pl-6 ">
<FormLabel>Network</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
Check the interface
<HelpCircle className="size-4 text-muted-foreground" />
</FormDescription>
</TooltipTrigger>
<TooltipContent
className="w-full z-[999]"
align="start"
side="bottom"
>
<code>
<pre>
{`[
{
"Target" : string | undefined;
"Aliases" : string[] | undefined;
"DriverOpts" : { [key: string]: string } | undefined;
}
]`}
</pre>
</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<FormControl>
<CodeEditor
language="json"
placeholder={`[
{
"Target" : "dokploy-network",
"Aliases" : ["dokploy-network"],
"DriverOpts" : {
"com.docker.network.driver.mtu" : "1500",
"com.docker.network.driver.host_binding" : "true",
"com.docker.network.driver.mtu" : "1500",
"com.docker.network.driver.host_binding" : "true"
}
}
]`}
className="h-[20rem] font-mono"
{...field}
value={field?.value || ""}
/>
</FormControl>
<pre>
<FormMessage />
</pre>
</FormItem>
)}
/>
<FormField
control={form.control}
name="labelsSwarm"
render={({ field }) => (
<FormItem className="relative max-lg:px-4 lg:pr-6 ">
<FormLabel>Labels</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
Check the interface
<HelpCircle className="size-4 text-muted-foreground" />
</FormDescription>
</TooltipTrigger>
<TooltipContent
className="w-full z-[999]"
align="start"
side="bottom"
>
<code>
<pre>
{`{
[name: string]: string;
}`}
</pre>
</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<FormControl>
<CodeEditor
language="json"
placeholder={`{
"com.example.app.name" : "my-app",
"com.example.app.version" : "1.0.0"
}`}
className="h-[20rem] font-mono"
{...field}
value={field?.value || ""}
/>
</FormControl>
<pre>
<FormMessage />
</pre>
</FormItem>
)}
/>
<DialogFooter className="flex w-full flex-row justify-end md:col-span-2 m-0 sticky bottom-0 right-0 bg-muted border p-2 ">
<Button
isLoading={isLoading}
form="hook-form-add-permissions"
type="submit"
>
Update
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,208 @@
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";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import Link from "next/link";
import { Server } from "lucide-react";
import { AddSwarmSettings } from "./modify-swarm-settings";
import { AlertBlock } from "@/components/shared/alert-block";
interface Props {
applicationId: string;
}
const AddRedirectchema = z.object({
replicas: z.number(),
registryId: z.string(),
});
type AddCommand = z.infer<typeof AddRedirectchema>;
export const ShowClusterSettings = ({ applicationId }: Props) => {
const { data } = api.application.one.useQuery(
{
applicationId,
},
{ enabled: !!applicationId },
);
const { data: registries } = api.registry.all.useQuery();
const utils = api.useUtils();
const { mutateAsync, isLoading } = api.application.update.useMutation();
const form = useForm<AddCommand>({
defaultValues: {
registryId: data?.registryId || "",
replicas: data?.replicas || 1,
},
resolver: zodResolver(AddRedirectchema),
});
useEffect(() => {
if (data?.command) {
form.reset({
registryId: data?.registryId || "",
replicas: data?.replicas || 1,
});
}
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
const onSubmit = async (data: AddCommand) => {
await mutateAsync({
applicationId,
registryId: data?.registryId === "none" ? null : data?.registryId,
replicas: data?.replicas,
})
.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">Cluster Settings</CardTitle>
<CardDescription>
Add the registry and the replicas of the application
</CardDescription>
</div>
<AddSwarmSettings applicationId={applicationId} />
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after modify the cluster settings to
apply the changes.
</AlertBlock>
<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="replicas"
render={({ field }) => (
<FormItem>
<FormLabel>Replicas</FormLabel>
<FormControl>
<Input
placeholder="1"
{...field}
onChange={(e) => {
field.onChange(Number(e.target.value));
}}
type="number"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{registries && registries?.length === 0 ? (
<div className="pt-10">
<div className="flex flex-col items-center gap-3">
<Server className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To use a cluster feature, you need to configure at least a
registry first. Please, go to{" "}
<Link
href="/dashboard/settings/cluster"
className="text-foreground"
>
Settings
</Link>{" "}
to do so.
</span>
</div>
</div>
) : (
<>
<FormField
control={form.control}
name="registryId"
render={({ field }) => (
<FormItem>
<FormLabel>Select a registry</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a registry" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{registries?.map((registry) => (
<SelectItem
key={registry.registryId}
value={registry.registryId}
>
{registry.registryName}
</SelectItem>
))}
<SelectItem value={"none"}>None</SelectItem>
<SelectLabel>
Registries ({registries?.length})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</FormItem>
)}
/>
</>
)}
<div className="flex justify-end">
<Button isLoading={isLoading} type="submit" className="w-fit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
};

View File

@@ -26,11 +26,11 @@ interface Props {
applicationId: string;
}
const AddRedirectchema = z.object({
const AddRedirectSchema = z.object({
command: z.string(),
});
type AddCommand = z.infer<typeof AddRedirectchema>;
type AddCommand = z.infer<typeof AddRedirectSchema>;
export const AddCommand = ({ applicationId }: Props) => {
const { data } = api.application.one.useQuery(
@@ -48,7 +48,7 @@ export const AddCommand = ({ applicationId }: Props) => {
defaultValues: {
command: "",
},
resolver: zodResolver(AddRedirectchema),
resolver: zodResolver(AddRedirectSchema),
});
useEffect(() => {

View File

@@ -18,8 +18,8 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { AlertBlock } from "@/components/shared/alert-block";
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";
@@ -33,7 +33,7 @@ import {
} from "@/components/ui/select";
import { z } from "zod";
const AddPortchema = z.object({
const AddPortSchema = z.object({
publishedPort: z.number().int().min(1).max(65535),
targetPort: z.number().int().min(1).max(65535),
protocol: z.enum(["tcp", "udp"], {
@@ -41,7 +41,7 @@ const AddPortchema = z.object({
}),
});
type AddPort = z.infer<typeof AddPortchema>;
type AddPort = z.infer<typeof AddPortSchema>;
interface Props {
applicationId: string;
@@ -62,7 +62,7 @@ export const AddPort = ({
publishedPort: 0,
targetPort: 0,
},
resolver: zodResolver(AddPortchema),
resolver: zodResolver(AddPortSchema),
});
useEffect(() => {
@@ -100,14 +100,7 @@ export const AddPort = ({
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>
)}
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
@@ -190,8 +183,7 @@ export const AddPort = ({
<SelectValue placeholder="Select a protocol" />
</SelectTrigger>
</FormControl>
<SelectContent defaultValue={"none"}>
<SelectItem value={"none"}>None</SelectItem>
<SelectContent>
<SelectItem value={"tcp"}>TCP</SelectItem>
<SelectItem value={"udp"}>UDP</SelectItem>
</SelectContent>

View File

@@ -11,6 +11,7 @@ import { Rss } from "lucide-react";
import { AddPort } from "./add-port";
import { DeletePort } from "./delete-port";
import { UpdatePort } from "./update-port";
import { AlertBlock } from "@/components/shared/alert-block";
interface Props {
applicationId: string;
}
@@ -47,7 +48,11 @@ export const ShowPorts = ({ applicationId }: Props) => {
<AddPort applicationId={applicationId}>Add Port</AddPort>
</div>
) : (
<div className="flex flex-col pt-2">
<div className="flex flex-col pt-2 gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after adding, editing, or
deleting the ports to apply the changes.
</AlertBlock>
<div className="flex flex-col gap-6">
{data?.ports.map((port) => (
<div key={port.portId}>

View File

@@ -18,8 +18,9 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { AlertBlock } from "@/components/shared/alert-block";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, Pencil } from "lucide-react";
import { PenBoxIcon, Pencil } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -98,7 +99,7 @@ export const UpdatePort = ({ portId }: Props) => {
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<Pencil className="size-4 text-muted-foreground" />
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
@@ -106,14 +107,7 @@ export const UpdatePort = ({ portId }: Props) => {
<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>
)}
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form

View File

@@ -19,8 +19,8 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { AlertBlock } from "@/components/shared/alert-block";
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";
@@ -98,14 +98,7 @@ export const AddRedirect = ({
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>
)}
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form

View File

@@ -19,8 +19,9 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { AlertBlock } from "@/components/shared/alert-block";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, Pencil } from "lucide-react";
import { PenBoxIcon, Pencil } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -93,7 +94,7 @@ export const UpdateRedirect = ({ redirectId }: Props) => {
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<Pencil className="size-4 text-muted-foreground" />
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
@@ -101,14 +102,7 @@ export const UpdateRedirect = ({ redirectId }: Props) => {
<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>
)}
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form

View File

@@ -18,8 +18,8 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { AlertBlock } from "@/components/shared/alert-block";
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";
@@ -90,14 +90,7 @@ export const AddSecurity = ({
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>
)}
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form

View File

@@ -18,8 +18,9 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { AlertBlock } from "@/components/shared/alert-block";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, Pencil } from "lucide-react";
import { PenBoxIcon, Pencil } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -88,7 +89,7 @@ export const UpdateSecurity = ({ securityId }: Props) => {
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<Pencil className="size-4 text-muted-foreground" />
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
@@ -96,14 +97,7 @@ export const UpdateSecurity = ({ securityId }: Props) => {
<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>
)}
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form

View File

@@ -21,6 +21,7 @@ import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
const addResourcesApplication = z.object({
memoryReservation: z.number().nullable().optional(),
@@ -84,6 +85,10 @@ export const ShowApplicationResources = ({ applicationId }: Props) => {
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after modify the resources to apply
the changes.
</AlertBlock>
<Form {...form}>
<form
id="hook-form"

View File

@@ -9,6 +9,7 @@ import {
import { api } from "@/utils/api";
import { File } from "lucide-react";
import { UpdateTraefikConfig } from "./update-traefik-config";
import { CodeEditor } from "@/components/shared/code-editor";
interface Props {
applicationId: string;
}
@@ -43,11 +44,13 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => {
</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">
<div className="flex flex-col gap-6 max-h-[35rem] min-h-[10rem] overflow-y-auto">
<CodeEditor
value={data || "Empty"}
disabled
className="font-mono"
/>
<div className="flex justify-end absolute z-50 right-6 top-6">
<UpdateTraefikConfig applicationId={applicationId} />
</div>
</div>

View File

@@ -16,15 +16,15 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { AlertBlock } from "@/components/shared/alert-block";
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";
import { CodeEditor } from "@/components/shared/code-editor";
const UpdateTraefikConfigSchema = z.object({
traefikConfig: z.string(),
@@ -116,20 +116,13 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
<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>
)}
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-update-traefik-config"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full py-4"
className="grid w-full py-4 overflow-auto"
>
<div className="flex flex-col">
<FormField
@@ -139,8 +132,8 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
<FormItem>
<FormLabel>Traefik config</FormLabel>
<FormControl>
<Textarea
className="h-[35rem]"
<CodeEditor
wrapperClassName="h-[35rem] font-mono"
placeholder={`http:
routers:
router-name:

View File

@@ -27,6 +27,7 @@ import { PlusIcon } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
interface Props {
serviceId: string;
serviceType:
@@ -36,7 +37,8 @@ interface Props {
| "mongo"
| "redis"
| "mysql"
| "mariadb";
| "mariadb"
| "compose";
refetch: () => void;
children?: React.ReactNode;
}
@@ -77,7 +79,7 @@ export const AddVolumes = ({
const { mutateAsync } = api.mounts.create.useMutation();
const form = useForm<AddMount>({
defaultValues: {
type: "bind",
type: serviceType === "compose" ? "file" : "bind",
hostPath: "",
mountPath: "",
},
@@ -147,7 +149,7 @@ export const AddVolumes = ({
<DialogTitle>Volumes / Mounts</DialogTitle>
</DialogHeader>
{/* {isError && (
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
<div className="flex items-center 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}
@@ -176,41 +178,52 @@ export const AddVolumes = ({
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">
{serviceType !== "compose" && (
<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>
)}
{serviceType !== "compose" && (
<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={cn(
serviceType === "compose" && "col-span-3",
"flex items-center space-x-3 space-y-0",
)}
>
<FormControl className="w-full">
<div>
<RadioGroupItem

View File

@@ -10,6 +10,8 @@ import { api } from "@/utils/api";
import { AlertTriangle, Package } from "lucide-react";
import { AddVolumes } from "./add-volumes";
import { DeleteVolume } from "./delete-volume";
import { UpdateVolume } from "./update-volume";
import { AlertBlock } from "@/components/shared/alert-block";
interface Props {
applicationId: string;
}
@@ -59,15 +61,12 @@ export const ShowVolumes = ({ applicationId }: Props) => {
</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">
<div className="flex flex-col pt-2 gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after adding, editing, or
deleting a mount to apply the changes.
</AlertBlock>
<div className="flex flex-col gap-6">
{data?.mounts.map((mount) => (
<div key={mount.mountId}>
<div
@@ -114,7 +113,12 @@ export const ShowVolumes = ({ applicationId }: Props) => {
</span>
</div>
</div>
<div>
<div className="flex flex-row gap-1">
<UpdateVolume
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
/>
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
</div>
</div>

View File

@@ -0,0 +1,263 @@
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 { AlertBlock } from "@/components/shared/alert-block";
import { zodResolver } from "@hookform/resolvers/zod";
import { Pencil } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Textarea } from "@/components/ui/textarea";
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 UpdateMount = z.infer<typeof mySchema>;
interface Props {
mountId: string;
type: "bind" | "volume" | "file";
refetch: () => void;
}
export const UpdateVolume = ({ mountId, type, refetch }: Props) => {
const utils = api.useUtils();
const { data } = api.mounts.one.useQuery(
{
mountId,
},
{
enabled: !!mountId,
},
);
const { mutateAsync, isLoading, error, isError } =
api.mounts.update.useMutation();
const form = useForm<UpdateMount>({
defaultValues: {
type,
hostPath: "",
mountPath: "",
},
resolver: zodResolver(mySchema),
});
const typeForm = form.watch("type");
useEffect(() => {
if (data) {
if (typeForm === "bind") {
form.reset({
hostPath: data.hostPath || "",
mountPath: data.mountPath,
type: "bind",
});
} else if (typeForm === "volume") {
form.reset({
volumeName: data.volumeName || "",
mountPath: data.mountPath,
type: "volume",
});
} else if (typeForm === "file") {
form.reset({
content: data.content || "",
mountPath: data.mountPath,
type: "file",
});
}
}
}, [form, form.reset, data]);
const onSubmit = async (data: UpdateMount) => {
if (data.type === "bind") {
await mutateAsync({
hostPath: data.hostPath,
mountPath: data.mountPath,
type: data.type,
mountId,
})
.then(() => {
toast.success("Mount Update");
})
.catch(() => {
toast.error("Error to update the Bind mount");
});
} else if (data.type === "volume") {
await mutateAsync({
volumeName: data.volumeName,
mountPath: data.mountPath,
type: data.type,
mountId,
})
.then(() => {
toast.success("Mount Update");
})
.catch(() => {
toast.error("Error to update the Volume mount");
});
} else if (data.type === "file") {
await mutateAsync({
content: data.content,
mountPath: data.mountPath,
type: data.type,
mountId,
})
.then(() => {
toast.success("Mount Update");
})
.catch(() => {
toast.error("Error to update the File mount");
});
}
refetch();
};
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 mount</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-update-volume"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<div className="flex flex-col gap-4">
{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>
<DialogFooter>
<Button
isLoading={isLoading}
form="hook-form-update-volume"
type="submit"
>
Update
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -25,8 +25,8 @@ export const DeleteApplication = ({ applicationId }: Props) => {
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" isLoading={isLoading}>
<TrashIcon className="size-4 " />
<Button variant="ghost" isLoading={isLoading}>
<TrashIcon className="size-4 text-muted-foreground" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>

View File

@@ -27,8 +27,9 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { AlertBlock } from "@/components/shared/alert-block";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, PlusIcon } from "lucide-react";
import { PlusIcon } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -106,14 +107,7 @@ export const AddDomain = ({
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>
)}
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form

View File

@@ -24,8 +24,8 @@ export const DeleteDomain = ({ domainId }: Props) => {
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" isLoading={isLoading}>
<TrashIcon className="size-4 " />
<Button variant="ghost" isLoading={isLoading}>
<TrashIcon className="size-4 text-muted-foreground " />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>

View File

@@ -40,7 +40,9 @@ export const ShowDomains = ({ applicationId }: Props) => {
</div>
{data && data?.length > 0 && (
<AddDomain applicationId={applicationId} />
<AddDomain applicationId={applicationId}>
<GlobeIcon className="size-4" /> Add Domain
</AddDomain>
)}
</CardHeader>
<CardContent className="flex w-full flex-row gap-4">
@@ -51,7 +53,9 @@ export const ShowDomains = ({ applicationId }: Props) => {
To access to the application is required to set at least 1
domain
</span>
<AddDomain applicationId={applicationId}>Add Domain</AddDomain>
<AddDomain applicationId={applicationId}>
<GlobeIcon className="size-4" /> Add Domain
</AddDomain>
</div>
) : (
<div className="flex w-full flex-col gap-4">
@@ -75,8 +79,10 @@ export const ShowDomains = ({ applicationId }: Props) => {
<Button variant="outline" disabled>
{item.https ? "HTTPS" : "HTTP"}
</Button>
<UpdateDomain domainId={item.domainId} />
<DeleteDomain domainId={item.domainId} />
<div className="flex flex-row gap-1">
<UpdateDomain domainId={item.domainId} />
<DeleteDomain domainId={item.domainId} />
</div>
</div>
);
})}

View File

@@ -27,8 +27,9 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { AlertBlock } from "@/components/shared/alert-block";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, PenBoxIcon } from "lucide-react";
import { PenBoxIcon } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -114,8 +115,8 @@ export const UpdateDomain = ({ domainId }: Props) => {
return (
<Dialog>
<DialogTrigger className="" asChild>
<Button>
<PenBoxIcon className="size-4" />
<Button variant="ghost">
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
@@ -125,14 +126,7 @@ export const UpdateDomain = ({ domainId }: Props) => {
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>
)}
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form

View File

@@ -20,6 +20,7 @@ import {
import { api } from "@/utils/api";
import { toast } from "sonner";
import { Textarea } from "@/components/ui/textarea";
import { CodeEditor } from "@/components/shared/code-editor";
const addEnvironmentSchema = z.object({
environment: z.string(),
@@ -94,9 +95,12 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Textarea
placeholder="NODE_ENV=production"
className="h-96"
<CodeEditor
language="properties"
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono"
{...field}
/>
</FormControl>

View File

@@ -11,6 +11,7 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { useEffect, useState } from "react";
import { toast } from "sonner";
interface Props {
@@ -51,18 +52,14 @@ export const DeployApplication = ({ applicationId }: Props) => {
applicationId,
})
.then(async () => {
toast.success("Application Deploying....");
toast.success("Deploying Application....");
await refetch();
await deploy({
applicationId,
})
.then(() => {
toast.success("Application Deployed Succesfully");
})
.catch(() => {
toast.error("Error to deploy Application");
});
}).catch(() => {
toast.error("Error to deploy Application");
});
await refetch();
})

View File

@@ -18,6 +18,7 @@ import {
} from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { toast } from "sonner";
@@ -89,7 +90,7 @@ export const UpdateApplication = ({ applicationId }: Props) => {
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost">
<SquarePen className="size-4" />
<SquarePen className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
@@ -97,14 +98,7 @@ export const UpdateApplication = ({ applicationId }: Props) => {
<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>
)}
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="grid gap-4">
<div className="grid items-center gap-4">

View File

@@ -0,0 +1,133 @@
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,
FormDescription,
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 {
composeId: string;
}
const AddRedirectSchema = z.object({
command: z.string(),
});
type AddCommand = z.infer<typeof AddRedirectSchema>;
export const AddCommandCompose = ({ composeId }: Props) => {
const { data } = api.compose.one.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
const { data: defaultCommand, refetch } =
api.compose.getDefaultCommand.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
const utils = api.useUtils();
const { mutateAsync, isLoading } = api.compose.update.useMutation();
const form = useForm<AddCommand>({
defaultValues: {
command: "",
},
resolver: zodResolver(AddRedirectSchema),
});
useEffect(() => {
if (data?.command) {
form.reset({
command: data?.command || "",
});
}
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
const onSubmit = async (data: AddCommand) => {
await mutateAsync({
composeId,
command: data?.command,
})
.then(async () => {
toast.success("Command Updated");
refetch();
await utils.compose.one.invalidate({
composeId,
});
})
.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>
Append a custom command to the compose file
</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>
<FormDescription>
Default Command ({defaultCommand})
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end">
<Button isLoading={isLoading} type="submit" className="w-fit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,133 @@
import React from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { Package } from "lucide-react";
import { DeleteVolume } from "../../application/advanced/volumes/delete-volume";
import { AddVolumes } from "../../application/advanced/volumes/add-volumes";
import { UpdateVolume } from "../../application/advanced/volumes/update-volume";
import { AlertBlock } from "@/components/shared/alert-block";
interface Props {
composeId: string;
}
export const ShowVolumesCompose = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
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 compose use the following config
to setup the volumes
</CardDescription>
</div>
{data && data?.mounts.length > 0 && (
<AddVolumes
serviceId={composeId}
refetch={refetch}
serviceType="compose"
>
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={composeId}
refetch={refetch}
serviceType="compose"
>
Add Volume
</AddVolumes>
</div>
) : (
<div className="flex flex-col pt-2 gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after adding, editing, or
deleting a mount to apply the changes.
</AlertBlock>
<div className="flex flex-col gap-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"
>
<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 w-40 truncate">
{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 className="flex flex-row gap-1">
<UpdateVolume
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
/>
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
</div>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
};

View 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 {
composeId: string;
}
export const DeleteCompose = ({ composeId }: Props) => {
const { mutateAsync, isLoading } = api.compose.delete.useMutation();
const { push } = useRouter();
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
compose and all its services.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
composeId,
})
.then((data) => {
push(`/dashboard/project/${data?.projectId}`);
toast.success("Compose delete succesfully");
})
.catch(() => {
toast.error("Error to delete the compose");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -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 {
composeId: string;
}
export const CancelQueuesCompose = ({ composeId }: Props) => {
const { mutateAsync, isLoading } = api.compose.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({
composeId,
})
.then(() => {
toast.success("Queues are being cleaned");
})
.catch((err) => {
toast.error(err.message);
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -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 {
composeId: string;
}
export const RefreshTokenCompose = ({ composeId }: Props) => {
const { mutateAsync } = api.compose.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 change the token
and all the previous tokens will be invalidated
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
composeId,
})
.then(() => {
utils.compose.one.invalidate({
composeId,
});
toast.success("Refresh Token updated");
})
.catch(() => {
toast.error("Error to update the refresh token");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -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 ShowDeploymentCompose = ({ 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>
);
};

View File

@@ -0,0 +1,119 @@
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-compose";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { ShowDeploymentCompose } from "./show-deployment-compose";
import { RefreshTokenCompose } from "./refresh-token-compose";
import { CancelQueuesCompose } from "./cancel-queues-compose";
// import { RefreshToken } from "./refresh-token";//
interface Props {
composeId: string;
}
export const ShowDeploymentsCompose = ({ composeId }: Props) => {
const [activeLog, setActiveLog] = useState<string | null>(null);
const { data } = api.compose.one.useQuery({ composeId });
const { data: deployments } = api.deployment.allByCompose.useQuery(
{ composeId },
{
enabled: !!composeId,
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 compose
</CardDescription>
</div>
<CancelQueuesCompose composeId={composeId} />
{/* <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/compose/${data?.refreshToken}`}
</span>
<RefreshTokenCompose composeId={composeId} />
</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>
)}
<ShowDeploymentCompose
open={activeLog !== null}
onClose={() => setActiveLog(null)}
logPath={activeLog}
/>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,122 @@
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 { CodeEditor } from "@/components/shared/code-editor";
const addEnvironmentSchema = z.object({
environment: z.string(),
});
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
interface Props {
composeId: string;
}
export const ShowEnvironmentCompose = ({ composeId }: Props) => {
const { mutateAsync, isLoading } = api.compose.update.useMutation();
const { data, refetch } = api.compose.one.useQuery(
{
composeId,
},
{
enabled: !!composeId,
},
);
const form = useForm<EnvironmentSchema>({
defaultValues: {
environment: "",
},
resolver: zodResolver(addEnvironmentSchema),
});
useEffect(() => {
if (data) {
form.reset({
environment: data.env || "",
});
}
}, [form.reset, data, form]);
const onSubmit = async (data: EnvironmentSchema) => {
mutateAsync({
env: data.environment,
composeId,
})
.then(async () => {
toast.success("Environments Added");
await refetch();
})
.catch(() => {
toast.error("Error to add environment");
});
};
return (
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Environment Settings</CardTitle>
<CardDescription>
You can add environment 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="environment"
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<CodeEditor
language="properties"
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono"
{...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>
);
};

View File

@@ -0,0 +1,121 @@
import { Button } from "@/components/ui/button";
import { ExternalLink, Globe, Terminal } from "lucide-react";
import { api } from "@/utils/api";
import { toast } from "sonner";
import { Toggle } from "@/components/ui/toggle";
import { RedbuildCompose } from "./rebuild-compose";
import { DeployCompose } from "./deploy-compose";
import { StopCompose } from "./stop-compose";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import Link from "next/link";
interface Props {
composeId: string;
}
export const ComposeActions = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
const { mutateAsync: update } = api.compose.update.useMutation();
const extractDomains = (env: string) => {
const lines = env.split("\n");
const hostLines = lines.filter((line) => {
const [key, value] = line.split("=");
return key?.trim().endsWith("_HOST");
});
const hosts = hostLines.map((line) => {
const [key, value] = line.split("=");
return value ? value.trim() : "";
});
return hosts;
};
const domains = extractDomains(data?.env || "");
return (
<div className="flex flex-row gap-4 w-full flex-wrap ">
<DeployCompose composeId={composeId} />
<Toggle
aria-label="Toggle italic"
pressed={data?.autoDeploy || false}
onPressedChange={async (enabled) => {
await update({
composeId,
autoDeploy: enabled,
})
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
})
.catch(() => {
toast.error("Error to update Auto Deploy");
});
}}
>
Autodeploy
</Toggle>
<RedbuildCompose composeId={composeId} />
{data?.composeType === "docker-compose" && (
<StopCompose composeId={composeId} />
)}
<DockerTerminalModal appName={data?.appName || ""}>
<Button variant="outline">
<Terminal />
Open Terminal
</Button>
</DockerTerminalModal>
{domains.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
Domains
<Globe className="text-xs size-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>Domains detected</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{domains.map((host, index) => {
const url =
host.startsWith("http://") || host.startsWith("https://")
? host
: `http://${host}`;
return (
<DropdownMenuItem
key={`domain-${index}`}
className="cursor-pointer"
asChild
>
<Link href={url} target="_blank">
{host}
<ExternalLink className="ml-2 text-xs text-muted-foreground" />
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
};

View File

@@ -0,0 +1,142 @@
import { api } from "@/utils/api";
import { useEffect } from "react";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { validateAndFormatYAML } from "../../application/advanced/traefik/update-traefik-config";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { RandomizeCompose } from "./randomize-compose";
import { CodeEditor } from "@/components/shared/code-editor";
interface Props {
composeId: string;
}
const AddComposeFile = z.object({
composeFile: z.string(),
});
type AddComposeFile = z.infer<typeof AddComposeFile>;
export const ComposeFileEditor = ({ composeId }: Props) => {
const utils = api.useUtils();
const { data, refetch } = api.compose.one.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
const { mutateAsync, isLoading, error, isError } =
api.compose.update.useMutation();
const form = useForm<AddComposeFile>({
defaultValues: {
composeFile: "",
},
resolver: zodResolver(AddComposeFile),
});
useEffect(() => {
if (data) {
form.reset({
composeFile: data.composeFile || "",
});
}
}, [form, form.reset, data]);
const onSubmit = async (data: AddComposeFile) => {
const { valid, error } = validateAndFormatYAML(data.composeFile);
if (!valid) {
form.setError("composeFile", {
type: "manual",
message: error || "Invalid YAML",
});
return;
}
form.clearErrors("composeFile");
await mutateAsync({
composeId,
composeFile: data.composeFile,
sourceType: "raw",
})
.then(async () => {
toast.success("Compose config Updated");
refetch();
await utils.compose.allServices.invalidate({
composeId,
});
})
.catch((e) => {
console.log(e);
toast.error("Error to update the compose config");
});
};
return (
<>
<div className="w-full flex flex-col lg:flex-row gap-4 ">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full relative gap-4"
>
<FormField
control={form.control}
name="composeFile"
render={({ field }) => (
<FormItem className="overflow-auto">
<FormControl className="">
<div className="flex flex-col gap-4 w-full outline-none focus:outline-none overflow-auto">
<CodeEditor
// disabled
value={field.value}
className="font-mono min-h-[20rem] compose-file-editor"
wrapperClassName="min-h-[20rem]"
placeholder={`version: '3'
services:
web:
image: nginx
ports:
- "80:80"
`}
onChange={(value) => {
field.onChange(value);
}}
/>
</div>
</FormControl>
<pre>
<FormMessage />
</pre>
</FormItem>
)}
/>
<div className="flex justify-between flex-col lg:flex-row gap-2">
<div className="w-full flex flex-col lg:flex-row gap-4 items-end">
<RandomizeCompose composeId={composeId} />
</div>
<Button
type="submit"
isLoading={isLoading}
className="lg:w-fit w-full"
>
Save
</Button>
</div>
</form>
</Form>
</div>
</>
);
};

View File

@@ -0,0 +1,74 @@
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 {
composeId: string;
}
export const DeployCompose = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
const { mutateAsync: markRunning } = api.compose.update.useMutation();
const { mutateAsync: deploy } = api.compose.deploy.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button isLoading={data?.composeStatus === "running"}>Deploy</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This will deploy the compose
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await markRunning({
composeId,
composeStatus: "running",
})
.then(async () => {
toast.success("Deploying Compose....");
await refetch();
await deploy({
composeId,
}).catch(() => {
toast.error("Error to deploy Compose");
});
await refetch();
})
.catch((e) => {
toast.error(e.message || "Error to deploy Compose");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,255 @@
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({
composePath: z.string().min(1),
repositoryURL: z.string().min(1, {
message: "Repository URL is required",
}),
branch: z.string().min(1, "Branch required"),
});
type GitProvider = z.infer<typeof GitProviderSchema>;
interface Props {
composeId: string;
}
export const SaveGitProviderCompose = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery({ composeId });
const { mutateAsync, isLoading } = api.compose.update.useMutation();
const { mutateAsync: generateSSHKey, isLoading: isGeneratingSSHKey } =
api.compose.generateSSHKey.useMutation();
const { mutateAsync: removeSSHKey, isLoading: isRemovingSSHKey } =
api.compose.removeSSHKey.useMutation();
const form = useForm<GitProvider>({
defaultValues: {
branch: "",
repositoryURL: "",
composePath: "./docker-compose.yml",
},
resolver: zodResolver(GitProviderSchema),
});
useEffect(() => {
if (data) {
form.reset({
branch: data.customGitBranch || "",
repositoryURL: data.customGitUrl || "",
composePath: data.composePath,
});
}
}, [form.reset, data, form]);
const onSubmit = async (values: GitProvider) => {
await mutateAsync({
customGitBranch: values.branch,
customGitUrl: values.repositoryURL,
composeId,
sourceType: "git",
composePath: values.composePath,
})
.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({
composeId,
})
.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({
composeId,
})
.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>
<FormField
control={form.control}
name="composePath"
render={({ field }) => (
<FormItem>
<FormLabel>Compose Path</FormLabel>
<FormControl>
<Input placeholder="docker-compose.yml" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex flex-row justify-end">
<Button type="submit" className="w-fit" isLoading={isLoading}>
Save{" "}
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,310 @@
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({
composePath: z.string().min(1),
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 {
composeId: string;
}
export const SaveGithubProviderCompose = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery({ composeId });
const { mutateAsync, isLoading: isSavingGithubProvider } =
api.compose.update.useMutation();
const form = useForm<GithubProvider>({
defaultValues: {
composePath: "./docker-compose.yml",
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 || "",
},
composePath: data.composePath,
});
}
}, [form.reset, data, form]);
const onSubmit = async (data: GithubProvider) => {
console.log(data);
await mutateAsync({
branch: data.branch,
repository: data.repository.repo,
composeId: composeId,
owner: data.repository.owner,
sourceType: "github",
composePath: data.composePath,
})
.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="composePath"
render={({ field }) => (
<FormItem>
<FormLabel>Compose Path</FormLabel>
<FormControl>
<Input placeholder="docker-compose.yml" {...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>
);
};

View File

@@ -0,0 +1,97 @@
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";
import { SaveGithubProviderCompose } from "./save-github-provider-compose";
import { ComposeFileEditor } from "../compose-file-editor";
import { SaveGitProviderCompose } from "./save-git-provider-compose";
type TabState = "github" | "git" | "raw";
interface Props {
composeId: string;
}
export const ShowProviderFormCompose = ({ composeId }: Props) => {
const { data: haveGithubConfigured } =
api.admin.haveGithubConfigured.useQuery();
const { data: compose } = api.compose.one.useQuery({ composeId });
const [tab, setSab] = useState<TabState>(compose?.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="git"
className="rounded-none border-b-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
>
Git
</TabsTrigger>
<TabsTrigger
value="raw"
className="rounded-none border-b-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
>
Raw
</TabsTrigger>
</TabsList>
<TabsContent value="github" className="w-full p-2">
{haveGithubConfigured ? (
<SaveGithubProviderCompose composeId={composeId} />
) : (
<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="git" className="w-full p-2">
<SaveGitProviderCompose composeId={composeId} />
</TabsContent>
<TabsContent value="raw" className="w-full p-2 flex flex-col gap-4">
<ComposeFileEditor composeId={composeId} />
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,98 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import { AlertBlock } from "@/components/shared/alert-block";
import { Dices } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
interface Props {
composeId: string;
}
export const RandomizeCompose = ({ composeId }: Props) => {
const utils = api.useUtils();
const [prefix, setPrefix] = useState<string>("");
const [compose, setCompose] = useState<string>("");
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, error, isError } =
api.compose.randomizeCompose.useMutation();
const onSubmit = async () => {
await mutateAsync({
composeId,
prefix,
})
.then(async (data) => {
await utils.project.all.invalidate();
setCompose(data);
toast.success("Compose randomized");
})
.catch(() => {
toast.error("Error to randomize the compose");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild onClick={() => onSubmit()}>
<Button className="max-lg:w-full" variant="outline">
<Dices className="h-4 w-4" />
Randomize Compose
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-6xl max-h-[50rem] overflow-y-auto">
<DialogHeader>
<DialogTitle>Randomize Compose (Experimental)</DialogTitle>
<DialogDescription>
Use this in case you want to deploy the same compose file and you
have conflicts with some property like volumes, networks, etc.
</DialogDescription>
</DialogHeader>
<div className="text-sm text-muted-foreground flex flex-col gap-2">
<span>
This will randomize the compose file and will add a prefix to the
property to avoid conflicts
</span>
<ul className="list-disc list-inside">
<li>volumes</li>
<li>networks</li>
<li>services</li>
<li>configs</li>
<li>secrets</li>
</ul>
</div>
<div className="flex flex-col lg:flex-row gap-2">
<Input
placeholder="Enter a prefix (Optional, example: prod)"
onChange={(e) => setPrefix(e.target.value)}
/>
<Button
type="submit"
onClick={async () => {
await onSubmit();
}}
className="lg:w-fit w-full"
>
Random
</Button>
</div>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="p-4 bg-secondary rounded-lg">
<pre>
<code className="language-yaml">{compose}</code>
</pre>
</div>
</DialogContent>
</Dialog>
);
};

View 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 {
composeId: string;
}
export const RedbuildCompose = ({ composeId }: Props) => {
const { data } = api.compose.one.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
const { mutateAsync: markRunning } = api.compose.update.useMutation();
const { mutateAsync } = api.compose.redeploy.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="secondary"
isLoading={data?.composeStatus === "running"}
>
Rebuild
<Hammer className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure to rebuild the compose?
</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({
composeId,
composeStatus: "running",
})
.then(async () => {
await mutateAsync({
composeId,
})
.then(async () => {
await utils.compose.one.invalidate({
composeId,
});
toast.success("Compose rebuild succesfully");
})
.catch(() => {
toast.error("Error to rebuild the compose");
});
})
.catch(() => {
toast.error("Error to rebuild the compose");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,47 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import React from "react";
import { ShowProviderFormCompose } from "./generic/show";
import { ComposeActions } from "./actions";
import { Badge } from "@/components/ui/badge";
import { api } from "@/utils/api";
interface Props {
composeId: string;
}
export const ShowGeneralCompose = ({ composeId }: Props) => {
const { data } = api.compose.one.useQuery(
{ composeId },
{
enabled: !!composeId,
},
);
return (
<>
<Card className="bg-background">
<CardHeader>
<div className="flex flex-row gap-2 justify-between flex-wrap">
<CardTitle className="text-xl">Deploy Settings</CardTitle>
<Badge>
{data?.composeType === "docker-compose" ? "Compose" : "Stack"}
</Badge>
</div>
<CardDescription>
Create a compose file to deploy your compose
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 flex-wrap">
<ComposeActions composeId={composeId} />
</CardContent>
</Card>
<ShowProviderFormCompose composeId={composeId} />
</>
);
};

View File

@@ -0,0 +1,79 @@
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 {
composeId: string;
}
export const StopCompose = ({ composeId }: Props) => {
const { data } = api.compose.one.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
const { mutateAsync: markRunning } = api.compose.update.useMutation();
const { mutateAsync, isLoading } = api.compose.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 sure to stop the compose?</AlertDialogTitle>
<AlertDialogDescription>
This will stop the compose services
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await markRunning({
composeId,
composeStatus: "running",
})
.then(async () => {
await mutateAsync({
composeId,
})
.then(async () => {
await utils.compose.one.invalidate({
composeId,
});
toast.success("Compose rebuild succesfully");
})
.catch(() => {
toast.error("Error to rebuild the compose");
});
})
.catch(() => {
toast.error("Error to rebuild the compose");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View 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 ShowDockerLogsCompose = ({ 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>
);
};

View File

@@ -0,0 +1,85 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { useEffect, useState } from "react";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { DockerMonitoring } from "../../monitoring/docker/show";
interface Props {
appName: string;
appType: "stack" | "docker-compose";
}
export const ShowMonitoringCompose = ({
appName,
appType = "stack",
}: Props) => {
const { data } = api.docker.getContainersByAppNameMatch.useQuery(
{
appName: appName,
},
{
enabled: !!appName,
},
);
const [containerAppName, setContainerAppName] = useState<
string | undefined
>();
useEffect(() => {
if (data && data?.length > 0) {
setContainerAppName(data[0]?.name);
}
}, [data]);
return (
<div>
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Monitoring</CardTitle>
<CardDescription>Watch the usage of your compose</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Label>Select a container to watch the monitoring</Label>
<Select onValueChange={setContainerAppName} value={containerAppName}>
<SelectTrigger>
<SelectValue placeholder="Select a container" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{data?.map((container) => (
<SelectItem
key={container.containerId}
value={container.name}
>
{container.name} ({container.containerId}) {container.state}
</SelectItem>
))}
<SelectLabel>Containers ({data?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<DockerMonitoring
appName={containerAppName || ""}
appType={appType}
/>
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,159 @@
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 { AlertBlock } from "@/components/shared/alert-block";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { SquarePen } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
const updateComposeSchema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
description: z.string().optional(),
});
type UpdateCompose = z.infer<typeof updateComposeSchema>;
interface Props {
composeId: string;
}
export const UpdateCompose = ({ composeId }: Props) => {
const utils = api.useUtils();
const { mutateAsync, error, isError, isLoading } =
api.compose.update.useMutation();
const { data } = api.compose.one.useQuery(
{
composeId,
},
{
enabled: !!composeId,
},
);
const form = useForm<UpdateCompose>({
defaultValues: {
description: data?.description ?? "",
name: data?.name ?? "",
},
resolver: zodResolver(updateComposeSchema),
});
useEffect(() => {
if (data) {
form.reset({
description: data.description ?? "",
name: data.name,
});
}
}, [data, form, form.reset]);
const onSubmit = async (formData: UpdateCompose) => {
await mutateAsync({
name: formData.name,
composeId: composeId,
description: formData.description || "",
})
.then(() => {
toast.success("Compose updated succesfully");
utils.compose.one.invalidate({
composeId: composeId,
});
})
.catch(() => {
toast.error("Error to update the Compose");
})
.finally(() => {});
};
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost">
<SquarePen className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Modify Compose</DialogTitle>
<DialogDescription>Update the compose data</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="grid gap-4">
<div className="grid items-center gap-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-update-compose"
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-compose"
type="submit"
>
Update
</Button>
</DialogFooter>
</form>
</Form>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -20,7 +20,7 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { Pencil, CheckIcon, ChevronsUpDown } from "lucide-react";
import { Pencil, CheckIcon, ChevronsUpDown, PenBoxIcon } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -115,7 +115,7 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost">
<Pencil className="size-4 text-muted-foreground" />
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">

View File

@@ -32,14 +32,14 @@ export const ShowContainerConfig = ({ containerId }: Props) => {
View Config
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className={"sm:max-w-5xl overflow-y-auto max-h-screen"}>
<DialogContent className={"w-full md:w-[70vw] max-w-max"}>
<DialogHeader>
<DialogTitle>Container Config</DialogTitle>
<DialogDescription>
See in detail the config of this container
</DialogDescription>
</DialogHeader>
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem] bg-card">
<div className="text-wrap rounded-lg border p-4 text-sm bg-card overflow-y-auto max-h-[80vh]">
<code>
<pre className="whitespace-pre-wrap break-words">
{JSON.stringify(data, null, 2)}

View File

@@ -24,11 +24,12 @@ export const DockerLogsId: React.FC<Props> = ({ id, containerId }) => {
cols: 80,
rows: 30,
lineHeight: 1.4,
fontWeight: 400,
convertEol: true,
theme: {
cursor: "transparent",
background: "#19191A",
background: "rgba(0, 0, 0, 0)",
},
});
@@ -81,8 +82,8 @@ export const DockerLogsId: React.FC<Props> = ({ id, containerId }) => {
/>
</div>
<div className="w-full h-full bg-input rounded-lg p-2 ">
<div id={id} className="rounded-xl" />
<div className="w-full h-full rounded-lg p-2 bg-[#19191A]">
<div id={id} />
</div>
</div>
);

View File

@@ -1,4 +1,3 @@
import React from "react";
import {
Dialog,
DialogContent,

View File

@@ -3,7 +3,7 @@ import { Terminal } from "@xterm/xterm";
import { FitAddon } from "xterm-addon-fit";
import "@xterm/xterm/css/xterm.css";
import { AttachAddon } from "@xterm/addon-attach";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
interface Props {
id: string;
@@ -12,7 +12,7 @@ interface Props {
export const DockerTerminal: React.FC<Props> = ({ id, containerId }) => {
const termRef = useRef(null);
const [activeWay, setActiveWay] = React.useState<string | null>("bash");
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
useEffect(() => {
const container = document.getElementById(id);
if (container) {
@@ -26,7 +26,7 @@ export const DockerTerminal: React.FC<Props> = ({ id, containerId }) => {
convertEol: true,
theme: {
cursor: "transparent",
background: "#19191A",
background: "rgba(0, 0, 0, 0)",
},
});
const addonFit = new FitAddon();
@@ -54,14 +54,15 @@ export const DockerTerminal: React.FC<Props> = ({ id, containerId }) => {
<span>
Select way to connect to <b>{containerId}</b>
</span>
<div className="flex flex-row gap-4 w-fit">
<Button onClick={() => setActiveWay("sh")}>SH</Button>
<Button onClick={() => setActiveWay("bash")}>Bash</Button>
<Button onClick={() => setActiveWay("sh")}>/bin/sh</Button>
</div>
<Tabs value={activeWay} onValueChange={setActiveWay}>
<TabsList>
<TabsTrigger value="bash">Bash</TabsTrigger>
<TabsTrigger value="sh">/bin/sh</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="w-full h-full bg-input rounded-lg p-2 ">
<div id={id} ref={termRef} className="rounded-xl" />
<div className="w-full h-full rounded-lg p-2 bg-[#19191A]">
<div id={id} ref={termRef} />
</div>
</div>
);

View File

@@ -9,15 +9,15 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { AlertBlock } from "@/components/shared/alert-block";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { validateAndFormatYAML } from "../application/advanced/traefik/update-traefik-config";
import { CodeEditor } from "@/components/shared/code-editor";
const UpdateServerMiddlewareConfigSchema = z.object({
traefikConfig: z.string(),
@@ -86,31 +86,25 @@ export const ShowTraefikFile = ({ path }: Props) => {
return (
<div>
{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>
)}
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full py-4 relative"
className="grid w-full relative z-[5]"
>
<div className="flex flex-col">
<div className="flex flex-col overflow-auto">
<FormField
control={form.control}
name="traefikConfig"
render={({ field }) => (
<FormItem className="relative">
<FormLabel>Traefik config</FormLabel>
<FormDescription>{path}</FormDescription>
<FormDescription className="break-all">
{path}
</FormDescription>
<FormControl>
<Textarea
className="h-[35rem]"
<CodeEditor
wrapperClassName="h-[35rem] font-mono"
placeholder={`http:
routers:
router-name:
@@ -128,8 +122,9 @@ routers:
<pre>
<FormMessage />
</pre>
<div className="flex justify-end absolute z-50 right-6 top-10">
<div className="flex justify-end absolute z-50 right-6 top-8">
<Button
className="shadow-sm"
variant="secondary"
type="button"
onClick={async () => {

View File

@@ -13,8 +13,8 @@ export const ShowTraefikSystem = () => {
const { data: directories } = api.settings.readDirectories.useQuery();
return (
<div className={cn("mt-6 grid gap-4 pb-20")}>
<div className="flex flex-col md:flex-row gap-4 md:gap-10 w-full">
<div className={cn("mt-6 md:grid gap-4")}>
<div className="flex flex-col lg:flex-row gap-4 md:gap-10 w-full">
{directories?.length === 0 && (
<div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]">
<span className="text-muted-foreground text-lg font-medium">
@@ -27,7 +27,7 @@ export const ShowTraefikSystem = () => {
<>
<Tree
data={directories}
className="md:max-w-[19rem] w-full md:h-[660px] border rounded-lg"
className="lg:max-w-[19rem] w-full lg:h-[660px] border rounded-lg"
onSelectChange={(item) => setFile(item?.id || null)}
folderIcon={Folder}
itemIcon={Workflow}

View File

@@ -1,18 +1,18 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
@@ -21,213 +21,218 @@ import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
const addResourcesMariadb = z.object({
memoryReservation: z.number().nullable().optional(),
cpuLimit: z.number().nullable().optional(),
memoryLimit: z.number().nullable().optional(),
cpuReservation: z.number().nullable().optional(),
memoryReservation: z.number().nullable().optional(),
cpuLimit: z.number().nullable().optional(),
memoryLimit: z.number().nullable().optional(),
cpuReservation: z.number().nullable().optional(),
});
interface Props {
mariadbId: string;
mariadbId: string;
}
type AddResourcesMariadb = z.infer<typeof addResourcesMariadb>;
export const ShowMariadbResources = ({ mariadbId }: Props) => {
const { data, refetch } = api.mariadb.one.useQuery(
{
mariadbId,
},
{ enabled: !!mariadbId },
);
const { mutateAsync, isLoading } = api.mariadb.update.useMutation();
const form = useForm<AddResourcesMariadb>({
defaultValues: {},
resolver: zodResolver(addResourcesMariadb),
});
const { data, refetch } = api.mariadb.one.useQuery(
{
mariadbId,
},
{ enabled: !!mariadbId },
);
const { mutateAsync, isLoading } = api.mariadb.update.useMutation();
const form = useForm<AddResourcesMariadb>({
defaultValues: {},
resolver: zodResolver(addResourcesMariadb),
});
useEffect(() => {
if (data) {
form.reset({
cpuLimit: data?.cpuLimit || undefined,
cpuReservation: data?.cpuReservation || undefined,
memoryLimit: data?.memoryLimit || undefined,
memoryReservation: data?.memoryReservation || undefined,
});
}
}, [data, form, form.formState.isSubmitSuccessful, form.reset]);
useEffect(() => {
if (data) {
form.reset({
cpuLimit: data?.cpuLimit || undefined,
cpuReservation: data?.cpuReservation || undefined,
memoryLimit: data?.memoryLimit || undefined,
memoryReservation: data?.memoryReservation || undefined,
});
}
}, [data, form, form.formState.isSubmitSuccessful, form.reset]);
const onSubmit = async (formData: AddResourcesMariadb) => {
await mutateAsync({
mariadbId,
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 === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
const onSubmit = async (formData: AddResourcesMariadb) => {
await mutateAsync({
mariadbId,
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">
<AlertBlock type="info">
Please remember to click Redeploy after modify the resources to apply
the changes.
</AlertBlock>
<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 === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<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 === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
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 === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
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}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuReservation"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Reservation</FormLabel>
<FormControl>
<Input
placeholder={"1"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
<FormField
control={form.control}
name="cpuLimit"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Limit</FormLabel>
<FormControl>
<Input
placeholder={"2"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuReservation"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Reservation</FormLabel>
<FormControl>
<Input
placeholder={"1"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
};

View File

@@ -1,13 +1,13 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
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";
@@ -16,47 +16,47 @@ import { useRouter } from "next/router";
import { toast } from "sonner";
interface Props {
mariadbId: string;
mariadbId: string;
}
export const DeleteMariadb = ({ mariadbId }: Props) => {
const { mutateAsync, isLoading } = api.mariadb.remove.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
database
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
mariadbId,
})
.then((data) => {
push(`/dashboard/project/${data?.projectId}`);
toast.success("Database delete succesfully");
})
.catch(() => {
toast.error("Error to delete the database");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
const { mutateAsync, isLoading } = api.mariadb.remove.useMutation();
const { push } = useRouter();
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
database
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
mariadbId,
})
.then((data) => {
push(`/dashboard/project/${data?.projectId}`);
toast.success("Database delete succesfully");
})
.catch(() => {
toast.error("Error to delete the database");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -19,7 +19,7 @@ import {
} from "@/components/ui/form";
import { api } from "@/utils/api";
import { toast } from "sonner";
import { Textarea } from "@/components/ui/textarea";
import { CodeEditor } from "@/components/shared/code-editor";
const addEnvironmentSchema = z.object({
environment: z.string(),
@@ -93,9 +93,12 @@ export const ShowMariadbEnvironment = ({ mariadbId }: Props) => {
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Textarea
placeholder="MARIADB_PASSWORD=1234567678"
className="h-96"
<CodeEditor
language="properties"
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono"
{...field}
/>
</FormControl>

View File

@@ -50,17 +50,13 @@ export const DeployMariadb = ({ mariadbId }: Props) => {
applicationStatus: "running",
})
.then(async () => {
toast.success("Database Deploying....");
toast.success("Deploying Database....");
await refetch();
await deploy({
mariadbId,
})
.then(() => {
toast.success("Database Deployed Succesfully");
})
.catch(() => {
toast.error("Error to deploy Database");
});
}).catch(() => {
toast.error("Error to deploy Database");
});
await refetch();
})
.catch((e) => {

View File

@@ -22,6 +22,7 @@ import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => {
@@ -136,7 +137,7 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
<div className="flex flex-col gap-3">
{/* jdbc:mariadb://5.161.59.207:3306/pixel-calculate?user=mariadb&password=HdVXfq6hM7W7F1 */}
<Label>External Host</Label>
<Input disabled value={connectionUrl} />
<ToggleVisibilityInput value={connectionUrl} disabled />
</div>
</div>
)}

View File

@@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
interface Props {
mariadbId: string;
@@ -29,20 +30,18 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<Input
<ToggleVisibilityInput
disabled
value={data?.databasePassword}
type="password"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label>Root Password</Label>
<div className="flex flex-row gap-4">
<Input
<ToggleVisibilityInput
disabled
value={data?.databaseRootPassword}
type="password"
/>
</div>
</div>
@@ -58,7 +57,7 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
<div className="flex flex-col gap-2 md:col-span-2">
<Label>Internal Connection URL </Label>
<Input
<ToggleVisibilityInput
disabled
value={`mariadb://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:3306/${data?.databaseName}`}
/>

View File

@@ -18,6 +18,7 @@ import {
} from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { toast } from "sonner";
@@ -89,7 +90,7 @@ export const UpdateMariadb = ({ mariadbId }: Props) => {
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost">
<SquarePen className="size-4" />
<SquarePen className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
@@ -97,14 +98,7 @@ export const UpdateMariadb = ({ mariadbId }: Props) => {
<DialogTitle>Modify MariaDB</DialogTitle>
<DialogDescription>Update the MariaDB 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>
)}
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="grid gap-4">
<div className="grid items-center gap-4">

View File

@@ -10,6 +10,8 @@ import { api } from "@/utils/api";
import { AlertTriangle, Package } from "lucide-react";
import { DeleteVolume } from "../../application/advanced/volumes/delete-volume";
import { AddVolumes } from "../../application/advanced/volumes/add-volumes";
import { UpdateVolume } from "../../application/advanced/volumes/update-volume";
import { AlertBlock } from "@/components/shared/alert-block";
interface Props {
mariadbId: string;
}
@@ -59,15 +61,12 @@ export const ShowVolumes = ({ mariadbId }: Props) => {
</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">
<div className="flex flex-col pt-2 gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after adding, editing, or
deleting a mount to apply the changes.
</AlertBlock>
<div className="flex flex-col gap-6">
{data?.mounts.map((mount) => (
<div key={mount.mountId}>
<div
@@ -113,7 +112,12 @@ export const ShowVolumes = ({ mariadbId }: Props) => {
</span>
</div>
</div>
<div>
<div className="flex flex-row gap-1">
<UpdateVolume
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
/>
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
</div>
</div>

View File

@@ -1,18 +1,18 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
@@ -21,213 +21,222 @@ import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
const addResourcesMongo = z.object({
memoryReservation: z.number().nullable().optional(),
cpuLimit: z.number().nullable().optional(),
memoryLimit: z.number().nullable().optional(),
cpuReservation: z.number().nullable().optional(),
memoryReservation: z.number().nullable().optional(),
cpuLimit: z.number().nullable().optional(),
memoryLimit: z.number().nullable().optional(),
cpuReservation: z.number().nullable().optional(),
});
interface Props {
mongoId: string;
mongoId: string;
}
type AddResourcesMongo = z.infer<typeof addResourcesMongo>;
export const ShowMongoResources = ({ mongoId }: Props) => {
const { data, refetch } = api.mongo.one.useQuery(
{
mongoId,
},
{ enabled: !!mongoId },
);
const { mutateAsync, isLoading } = api.mongo.update.useMutation();
const form = useForm<AddResourcesMongo>({
defaultValues: {},
resolver: zodResolver(addResourcesMongo),
});
const { data, refetch } = api.mongo.one.useQuery(
{
mongoId,
},
{ enabled: !!mongoId },
);
const { mutateAsync, isLoading } = api.mongo.update.useMutation();
const form = useForm<AddResourcesMongo>({
defaultValues: {},
resolver: zodResolver(addResourcesMongo),
});
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]);
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: AddResourcesMongo) => {
await mutateAsync({
mongoId,
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 === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
const onSubmit = async (formData: AddResourcesMongo) => {
await mutateAsync({
mongoId,
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">
<AlertBlock type="info">
Please remember to click Redeploy after modify the resources to apply
the changes.
</AlertBlock>
<Form {...form}>
<AlertBlock type="info">
Please remember to click Redeploy after modify the resources to
apply the changes.
</AlertBlock>
<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 === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<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 === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
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 === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
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}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuReservation"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Reservation</FormLabel>
<FormControl>
<Input
placeholder={"1"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
<FormField
control={form.control}
name="cpuLimit"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Limit</FormLabel>
<FormControl>
<Input
placeholder={"2"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuReservation"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Reservation</FormLabel>
<FormControl>
<Input
placeholder={"1"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
};

View File

@@ -1,13 +1,13 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
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";
@@ -16,47 +16,47 @@ import { useRouter } from "next/router";
import { toast } from "sonner";
interface Props {
mongoId: string;
mongoId: string;
}
export const DeleteMongo = ({ mongoId }: Props) => {
const { mutateAsync, isLoading } = api.mongo.remove.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
database
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
mongoId,
})
.then((data) => {
push(`/dashboard/project/${data?.projectId}`);
toast.success("Database delete succesfully");
})
.catch(() => {
toast.error("Error to delete the database");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
const { mutateAsync, isLoading } = api.mongo.remove.useMutation();
const { push } = useRouter();
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
database
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
mongoId,
})
.then((data) => {
push(`/dashboard/project/${data?.projectId}`);
toast.success("Database delete succesfully");
})
.catch(() => {
toast.error("Error to delete the database");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,3 +1,4 @@
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -13,7 +14,6 @@ import {
FormItem,
FormMessage,
} from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import React, { useEffect } from "react";
@@ -93,9 +93,12 @@ export const ShowMongoEnvironment = ({ mongoId }: Props) => {
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Textarea
placeholder="MONGO_PASSWORD=1234567678"
className="h-96"
<CodeEditor
language="properties"
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono"
{...field}
/>
</FormControl>

View File

@@ -50,17 +50,13 @@ export const DeployMongo = ({ mongoId }: Props) => {
applicationStatus: "running",
})
.then(async () => {
toast.success("Database Deploying....");
toast.success("Deploying Database....");
await refetch();
await deploy({
mongoId,
})
.then(() => {
toast.success("Database Deployed Succesfully");
})
.catch(() => {
toast.error("Error to deploy Database");
});
}).catch(() => {
toast.error("Error to deploy Database");
});
await refetch();
})
.catch((e) => {

View File

@@ -1,3 +1,4 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -136,7 +137,7 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
<div className="grid w-full gap-8">
<div className="flex flex-col gap-3">
<Label>External Host</Label>
<Input disabled value={connectionUrl} />
<ToggleVisibilityInput value={connectionUrl} disabled />
</div>
</div>
)}

View File

@@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
interface Props {
mongoId: string;
@@ -26,10 +27,9 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<Input
<ToggleVisibilityInput
disabled
value={data?.databasePassword}
type="password"
/>
</div>
</div>
@@ -46,7 +46,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<div className="flex flex-col gap-2 md:col-span-2">
<Label>Internal Connection URL </Label>
<Input
<ToggleVisibilityInput
disabled
value={`mongodb://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:27017`}
/>

View File

@@ -18,6 +18,7 @@ import {
} from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { toast } from "sonner";
@@ -89,7 +90,7 @@ export const UpdateMongo = ({ mongoId }: Props) => {
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost">
<SquarePen className="size-4" />
<SquarePen className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
@@ -97,14 +98,7 @@ export const UpdateMongo = ({ mongoId }: Props) => {
<DialogTitle>Modify MongoDB</DialogTitle>
<DialogDescription>Update the MongoDB 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>
)}
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="grid gap-4">
<div className="grid items-center gap-4">

View File

@@ -10,6 +10,8 @@ import { api } from "@/utils/api";
import { AlertTriangle, Package } from "lucide-react";
import { DeleteVolume } from "../../application/advanced/volumes/delete-volume";
import { AddVolumes } from "../../application/advanced/volumes/add-volumes";
import { UpdateVolume } from "../../application/advanced/volumes/update-volume";
import { AlertBlock } from "@/components/shared/alert-block";
interface Props {
mongoId: string;
}
@@ -55,15 +57,12 @@ export const ShowVolumes = ({ mongoId }: Props) => {
</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">
<div className="flex flex-col pt-2 gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after adding, editing, or
deleting a mount to apply the changes.
</AlertBlock>
<div className="flex flex-col gap-6">
{data?.mounts.map((mount) => (
<div key={mount.mountId}>
<div
@@ -109,7 +108,12 @@ export const ShowVolumes = ({ mongoId }: Props) => {
</span>
</div>
</div>
<div>
<div className="flex flex-row gap-1">
<UpdateVolume
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
/>
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
</div>
</div>

View File

@@ -14,8 +14,43 @@ import { DockerNetworkChart } from "./docker-network-chart";
import { DockerDiskChart } from "./docker-disk-chart";
import { api } from "@/utils/api";
const defaultData = {
cpu: {
value: 0,
time: "",
},
memory: {
value: {
used: 0,
free: 0,
usedPercentage: 0,
total: 0,
},
time: "",
},
block: {
value: {
readMb: 0,
writeMb: 0,
},
time: "",
},
network: {
value: {
inputMb: 0,
outputMb: 0,
},
time: "",
},
disk: {
value: { diskTotal: 0, diskUsage: 0, diskUsedPercentage: 0, diskFree: 0 },
time: "",
},
};
interface Props {
appName: string;
appType?: "application" | "stack" | "docker-compose";
}
export interface DockerStats {
cpu: {
@@ -65,7 +100,10 @@ export type DockerStatsJSON = {
disk: DockerStats["disk"][];
};
export const DockerMonitoring = ({ appName }: Props) => {
export const DockerMonitoring = ({
appName,
appType = "application",
}: Props) => {
const { data } = api.application.readAppMonitoring.useQuery(
{ appName },
{
@@ -79,39 +117,19 @@ export const DockerMonitoring = ({ appName }: Props) => {
network: [],
disk: [],
});
const [currentData, setCurrentData] = useState<DockerStats>({
cpu: {
value: 0,
time: "",
},
memory: {
value: {
used: 0,
free: 0,
usedPercentage: 0,
total: 0,
},
time: "",
},
block: {
value: {
readMb: 0,
writeMb: 0,
},
time: "",
},
network: {
value: {
inputMb: 0,
outputMb: 0,
},
time: "",
},
disk: {
value: { diskTotal: 0, diskUsage: 0, diskUsedPercentage: 0, diskFree: 0 },
time: "",
},
});
const [currentData, setCurrentData] = useState<DockerStats>(defaultData);
useEffect(() => {
setCurrentData(defaultData);
setAcummulativeData({
cpu: [],
memory: [],
block: [],
network: [],
disk: [],
});
}, [appName]);
useEffect(() => {
if (!data) return;
@@ -128,7 +146,7 @@ export const DockerMonitoring = ({ appName }: Props) => {
useEffect(() => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}`;
const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}&appType=${appType}`;
const ws = new WebSocket(wsUrl);
ws.onmessage = (e) => {

View File

@@ -1,18 +1,18 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
@@ -21,213 +21,218 @@ import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
const addResourcesMysql = z.object({
memoryReservation: z.number().nullable().optional(),
cpuLimit: z.number().nullable().optional(),
memoryLimit: z.number().nullable().optional(),
cpuReservation: z.number().nullable().optional(),
memoryReservation: z.number().nullable().optional(),
cpuLimit: z.number().nullable().optional(),
memoryLimit: z.number().nullable().optional(),
cpuReservation: z.number().nullable().optional(),
});
interface Props {
mysqlId: string;
mysqlId: string;
}
type AddResourcesMysql = z.infer<typeof addResourcesMysql>;
export const ShowMysqlResources = ({ mysqlId }: Props) => {
const { data, refetch } = api.mysql.one.useQuery(
{
mysqlId,
},
{ enabled: !!mysqlId },
);
const { mutateAsync, isLoading } = api.mysql.update.useMutation();
const form = useForm<AddResourcesMysql>({
defaultValues: {},
resolver: zodResolver(addResourcesMysql),
});
const { data, refetch } = api.mysql.one.useQuery(
{
mysqlId,
},
{ enabled: !!mysqlId },
);
const { mutateAsync, isLoading } = api.mysql.update.useMutation();
const form = useForm<AddResourcesMysql>({
defaultValues: {},
resolver: zodResolver(addResourcesMysql),
});
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]);
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: AddResourcesMysql) => {
await mutateAsync({
mysqlId,
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 === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
const onSubmit = async (formData: AddResourcesMysql) => {
await mutateAsync({
mysqlId,
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">
<AlertBlock type="info">
Please remember to click Redeploy after modify the resources to apply
the changes.
</AlertBlock>
<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 === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<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 === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
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 === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
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}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuReservation"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Reservation</FormLabel>
<FormControl>
<Input
placeholder={"1"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
<FormField
control={form.control}
name="cpuLimit"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Limit</FormLabel>
<FormControl>
<Input
placeholder={"2"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuReservation"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Reservation</FormLabel>
<FormControl>
<Input
placeholder={"1"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
};

View File

@@ -1,13 +1,13 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
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";
@@ -16,47 +16,47 @@ import { useRouter } from "next/router";
import { toast } from "sonner";
interface Props {
mysqlId: string;
mysqlId: string;
}
export const DeleteMysql = ({ mysqlId }: Props) => {
const { mutateAsync, isLoading } = api.mysql.remove.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
database
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
mysqlId,
})
.then((data) => {
push(`/dashboard/project/${data?.projectId}`);
toast.success("Database delete succesfully");
})
.catch(() => {
toast.error("Error to delete the database");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
const { mutateAsync, isLoading } = api.mysql.remove.useMutation();
const { push } = useRouter();
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
database
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
mysqlId,
})
.then((data) => {
push(`/dashboard/project/${data?.projectId}`);
toast.success("Database delete succesfully");
})
.catch(() => {
toast.error("Error to delete the database");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -19,7 +19,7 @@ import {
} from "@/components/ui/form";
import { api } from "@/utils/api";
import { toast } from "sonner";
import { Textarea } from "@/components/ui/textarea";
import { CodeEditor } from "@/components/shared/code-editor";
const addEnvironmentSchema = z.object({
environment: z.string(),
@@ -93,9 +93,12 @@ export const ShowMysqlEnvironment = ({ mysqlId }: Props) => {
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Textarea
placeholder="MYSQL_PASSWORD=1234567678"
className="h-96"
<CodeEditor
language="properties"
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono"
{...field}
/>
</FormControl>

View File

@@ -50,17 +50,13 @@ export const DeployMysql = ({ mysqlId }: Props) => {
applicationStatus: "running",
})
.then(async () => {
toast.success("Database Deploying....");
toast.success("Deploying Database....");
await refetch();
await deploy({
mysqlId,
})
.then(() => {
toast.success("Database Deployed Succesfully");
})
.catch(() => {
toast.error("Error to deploy Database");
});
}).catch(() => {
toast.error("Error to deploy Database");
});
await refetch();
})
.catch((e) => {

View File

@@ -1,3 +1,4 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -136,7 +137,7 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
<div className="grid w-full gap-8">
<div className="flex flex-col gap-3">
<Label>External Host</Label>
<Input disabled value={connectionUrl} />
<ToggleVisibilityInput disabled value={connectionUrl} />
</div>
</div>
)}

View File

@@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
interface Props {
mysqlId: string;
@@ -29,20 +30,18 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<Input
<ToggleVisibilityInput
disabled
value={data?.databasePassword}
type="password"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label>Root Password</Label>
<div className="flex flex-row gap-4">
<Input
<ToggleVisibilityInput
disabled
value={data?.databaseRootPassword}
type="password"
/>
</div>
</div>
@@ -58,7 +57,7 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
<div className="flex flex-col gap-2 md:col-span-2">
<Label>Internal Connection URL </Label>
<Input
<ToggleVisibilityInput
disabled
value={`mysql://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:3306/${data?.databaseName}`}
/>

View File

@@ -18,6 +18,7 @@ import {
} from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { toast } from "sonner";
@@ -89,7 +90,7 @@ export const UpdateMysql = ({ mysqlId }: Props) => {
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost">
<SquarePen className="size-4" />
<SquarePen className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
@@ -97,14 +98,7 @@ export const UpdateMysql = ({ mysqlId }: Props) => {
<DialogTitle>Modify MySQL</DialogTitle>
<DialogDescription>Update the MySQL 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>
)}
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="grid gap-4">
<div className="grid items-center gap-4">

View File

@@ -10,6 +10,8 @@ import { api } from "@/utils/api";
import { AlertTriangle, Package } from "lucide-react";
import { DeleteVolume } from "../../application/advanced/volumes/delete-volume";
import { AddVolumes } from "../../application/advanced/volumes/add-volumes";
import { UpdateVolume } from "../../application/advanced/volumes/update-volume";
import { AlertBlock } from "@/components/shared/alert-block";
interface Props {
mysqlId: string;
}
@@ -55,15 +57,12 @@ export const ShowVolumes = ({ mysqlId }: Props) => {
</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">
<div className="flex flex-col pt-2 gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after adding, editing, or
deleting a mount to apply the changes.
</AlertBlock>
<div className="flex flex-col gap-6">
{data?.mounts.map((mount) => (
<div key={mount.mountId}>
<div
@@ -109,7 +108,12 @@ export const ShowVolumes = ({ mysqlId }: Props) => {
</span>
</div>
</div>
<div>
<div className="flex flex-row gap-1">
<UpdateVolume
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
/>
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
</div>
</div>

View File

@@ -21,6 +21,7 @@ import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
const addResourcesPostgres = z.object({
memoryReservation: z.number().nullable().optional(),
@@ -83,6 +84,10 @@ export const ShowPostgresResources = ({ postgresId }: Props) => {
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after modify the resources to apply
the changes.
</AlertBlock>
<Form {...form}>
<form
id="hook-form"

View File

@@ -1,13 +1,13 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
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";
@@ -16,47 +16,47 @@ import { useRouter } from "next/router";
import { toast } from "sonner";
interface Props {
postgresId: string;
postgresId: string;
}
export const DeletePostgres = ({ postgresId }: Props) => {
const { mutateAsync, isLoading } = api.postgres.remove.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
database
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
postgresId,
})
.then((data) => {
push(`/dashboard/project/${data?.projectId}`);
toast.success("Database delete succesfully");
})
.catch(() => {
toast.error("Error to delete the database");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
const { mutateAsync, isLoading } = api.postgres.remove.useMutation();
const { push } = useRouter();
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
database
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
postgresId,
})
.then((data) => {
push(`/dashboard/project/${data?.projectId}`);
toast.success("Database delete succesfully");
})
.catch(() => {
toast.error("Error to delete the database");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -19,7 +19,7 @@ import {
} from "@/components/ui/form";
import { api } from "@/utils/api";
import { toast } from "sonner";
import { Textarea } from "@/components/ui/textarea";
import { CodeEditor } from "@/components/shared/code-editor";
const addEnvironmentSchema = z.object({
environment: z.string(),
@@ -93,9 +93,12 @@ export const ShowPostgresEnvironment = ({ postgresId }: Props) => {
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Textarea
placeholder="POSTGRES_PASSWORD=1234567678"
className="h-96"
<CodeEditor
language="properties"
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono"
{...field}
/>
</FormControl>

View File

@@ -50,17 +50,13 @@ export const DeployPostgres = ({ postgresId }: Props) => {
applicationStatus: "running",
})
.then(async () => {
toast.success("Database Deploying....");
toast.success("Deploying Database....");
await refetch();
await deploy({
postgresId,
})
.then(() => {
toast.success("Database Deployed Succesfully");
})
.catch(() => {
toast.error("Error to deploy Database");
});
}).catch(() => {
toast.error("Error to deploy Database");
});
await refetch();
})
.catch((e) => {

View File

@@ -1,3 +1,4 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -137,7 +138,7 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
<div className="grid w-full gap-8">
<div className="flex flex-col gap-3">
<Label>External Host</Label>
<Input disabled value={connectionUrl} />
<ToggleVisibilityInput value={connectionUrl} disabled />
</div>
</div>
)}

View File

@@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
interface Props {
postgresId: string;
@@ -29,10 +30,9 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<Input
disabled
<ToggleVisibilityInput
value={data?.databasePassword}
type="password"
disabled
/>
</div>
</div>
@@ -48,7 +48,7 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Internal Connection URL </Label>
<Input
<ToggleVisibilityInput
disabled
value={`postgresql://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:5432/${data?.databaseName}`}
/>

View File

@@ -18,6 +18,7 @@ import {
} from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { toast } from "sonner";
@@ -89,7 +90,7 @@ export const UpdatePostgres = ({ postgresId }: Props) => {
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost">
<SquarePen className="size-4" />
<SquarePen className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
@@ -97,14 +98,7 @@ export const UpdatePostgres = ({ postgresId }: Props) => {
<DialogTitle>Modify Postgres</DialogTitle>
<DialogDescription>Update the Postgres 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>
)}
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="grid gap-4">
<div className="grid items-center gap-4">

View File

@@ -10,6 +10,8 @@ import { api } from "@/utils/api";
import { AlertTriangle, Package } from "lucide-react";
import { AddVolumes } from "../../application/advanced/volumes/add-volumes";
import { DeleteVolume } from "../../application/advanced/volumes/delete-volume";
import { UpdateVolume } from "../../application/advanced/volumes/update-volume";
import { AlertBlock } from "@/components/shared/alert-block";
interface Props {
postgresId: string;
}
@@ -59,15 +61,12 @@ export const ShowVolumes = ({ postgresId }: Props) => {
</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">
<div className="flex flex-col pt-2 gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after adding, editing, or
deleting a mount to apply the changes.
</AlertBlock>
<div className="flex flex-col gap-6">
{data?.mounts.map((mount) => (
<div key={mount.mountId}>
<div
@@ -114,7 +113,12 @@ export const ShowVolumes = ({ postgresId }: Props) => {
</span>
</div>
</div>
<div>
<div className="flex flex-row gap-1">
<UpdateVolume
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
/>
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
</div>
</div>

View File

@@ -18,9 +18,10 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { AlertBlock } from "@/components/shared/alert-block";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, Folder } from "lucide-react";
import { Folder } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -104,14 +105,7 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
Assign a name and description 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>
)}
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form

View File

@@ -0,0 +1,186 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { AlertBlock } from "@/components/shared/alert-block";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { CircuitBoard, Folder } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const AddComposeSchema = z.object({
composeType: z.enum(["docker-compose", "stack"]).optional(),
name: z.string().min(1, {
message: "Name is required",
}),
description: z.string().optional(),
});
type AddCompose = z.infer<typeof AddComposeSchema>;
interface Props {
projectId: string;
}
export const AddCompose = ({ projectId }: Props) => {
const utils = api.useUtils();
const { mutateAsync, isLoading, error, isError } =
api.compose.create.useMutation();
const form = useForm<AddCompose>({
defaultValues: {
name: "",
description: "",
composeType: "docker-compose",
},
resolver: zodResolver(AddComposeSchema),
});
useEffect(() => {
form.reset();
}, [form, form.reset, form.formState.isSubmitSuccessful]);
const onSubmit = async (data: AddCompose) => {
await mutateAsync({
name: data.name,
description: data.description,
projectId,
composeType: data.composeType,
})
.then(async () => {
toast.success("Compose Created");
await utils.project.one.invalidate({
projectId,
});
})
.catch(() => {
toast.error("Error to create the compose");
});
};
return (
<Dialog>
<DialogTrigger className="w-full">
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
<CircuitBoard className="size-4 text-muted-foreground" />
<span>Compose</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>Create Compose</DialogTitle>
<DialogDescription>
Assign a name and description to your compose
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Frontend" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="composeType"
render={({ field }) => (
<FormItem>
<FormLabel>Compose Type</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a compose type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="docker-compose">
Docker Compose
</SelectItem>
<SelectItem value="stack">Stack</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Description about your service..."
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
<DialogFooter>
<Button isLoading={isLoading} form="hook-form" type="submit">
Create
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,209 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { AlertBlock } from "@/components/shared/alert-block";
import { api } from "@/utils/api";
import { Code, Github, Globe, PuzzleIcon } from "lucide-react";
import Link from "next/link";
import { Input } from "@/components/ui/input";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";
interface Props {
projectId: string;
}
export const AddTemplate = ({ projectId }: Props) => {
const [query, setQuery] = useState("");
const { data } = api.compose.templates.useQuery();
const utils = api.useUtils();
const { mutateAsync, isLoading, error, isError } =
api.compose.deployTemplate.useMutation();
const templates = data?.filter((t) =>
t.name.toLowerCase().includes(query.toLowerCase()),
);
return (
<Dialog>
<DialogTrigger className="w-full">
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
<PuzzleIcon className="size-4 text-muted-foreground" />
<span>Templates</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-7xl p-0">
<div className="sticky top-0 z-10 flex flex-col gap-4 bg-black p-6 border-b">
<DialogHeader>
<DialogTitle>Create Template</DialogTitle>
<DialogDescription>
Deploy a open source template to your project
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Input
placeholder="Search Template"
onChange={(e) => setQuery(e.target.value)}
value={query}
/>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 w-full gap-4">
{templates?.map((template, index) => (
<div key={`template-${index}`}>
<div
key={template.id}
className="flex flex-col gap-4 border p-6 rounded-lg h-full"
>
<div className="flex flex-col gap-4">
<div className="flex flex-col items-center gap-2">
<img
src={`/templates/${template.logo}`}
className="size-28 object-contain"
alt=""
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 justify-center items-center">
<div className="flex flex-col gap-2 items-center justify-center">
<div className="flex flex-row gap-2 flex-wrap">
<span className="text-sm font-medium">
{template.name}
</span>
<Badge>{template.version}</Badge>
</div>
<div className="flex flex-row gap-0">
<Link
href={template.links.github}
target="_blank"
className={
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors"
}
>
<Github className="size-4 text-muted-foreground" />
</Link>
{template.links.website && (
<Link
href={template.links.website}
target="_blank"
className={
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors"
}
>
<Globe className="size-4 text-muted-foreground" />
</Link>
)}
{template.links.docs && (
<Link
href={template.links.docs}
target="_blank"
className={
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors"
}
>
<Globe className="size-4 text-muted-foreground" />
</Link>
)}
<Link
href={`https://github.com/dokploy/dokploy/tree/canary/templates/${template.id}`}
target="_blank"
className={
"text-sm text-muted-foreground p-3 rounded-full hover:bg-border items-center flex transition-colors"
}
>
<Code className="size-4 text-muted-foreground" />
</Link>
</div>
<div className="flex flex-row gap-2 flex-wrap justify-center">
{template.tags.map((tag) => (
<Badge variant="secondary" key={tag}>
{tag}
</Badge>
))}
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button onSelect={(e) => e.preventDefault()}>
Deploy
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you absolutely sure?
</AlertDialogTitle>
<AlertDialogDescription>
This will deploy {template.name} template to
your project.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
projectId,
id: template.id,
})
.then(async () => {
toast.success(
`${template.name} template created succesfully`,
);
utils.project.one.invalidate({
projectId,
});
})
.catch(() => {
toast.error(
`Error to delete ${template.name} template`,
);
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<p className="text-sm text-muted-foreground">
{template.description}
</p>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -19,8 +19,9 @@ import {
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { AlertBlock } from "@/components/shared/alert-block";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, PlusIcon } from "lucide-react";
import { PlusIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -85,14 +86,7 @@ export const AddProject = () => {
<DialogTitle>Add a project</DialogTitle>
<DialogDescription>The home of something big!</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>
)}
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-add-project"

View File

@@ -48,9 +48,9 @@ export const ShowProjects = () => {
return (
<>
{data?.length === 0 && (
<div className="mt-6 flex h-[50vh] w-full flex-col items-center justify-center ">
<FolderInput className="size-10 md:size-28 text-muted" />
<span className="text-center font-medium text-muted-foreground">
<div className="mt-6 flex h-[50vh] w-full flex-col items-center justify-center space-y-4">
<FolderInput className="size-10 md:size-28 text-muted-foreground" />
<span className="text-center font-medium text-muted-foreground">
No projects added yet. Click on Create project.
</span>
</div>
@@ -64,6 +64,7 @@ export const ShowProjects = () => {
project?.postgres.length === 0 &&
project?.redis.length === 0 &&
project?.applications.length === 0;
project?.compose.length === 0;
const totalServices =
project?.mariadb.length +
@@ -71,7 +72,8 @@ export const ShowProjects = () => {
project?.mysql.length +
project?.postgres.length +
project?.redis.length +
project?.applications.length;
project?.applications.length +
project?.compose.length;
return (
<div key={project.projectId} className="w-full lg:max-w-md">
<Card className="group relative w-full bg-transparent transition-colors hover:bg-card">
@@ -89,9 +91,12 @@ export const ShowProjects = () => {
<span className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<BookIcon className="size-4 text-muted-foreground" />
<span className="text-base font-medium leading-none">
<Link
className="text-base font-medium leading-none"
href={`/dashboard/project/${project.projectId}`}
>
{project.name}
</span>
</Link>
</div>
<span className="text-sm font-medium text-muted-foreground">

View File

@@ -18,6 +18,7 @@ import {
} from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { toast } from "sonner";
@@ -101,14 +102,7 @@ export const UpdateProject = ({ projectId }: Props) => {
Update the details of the project
</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>
)}
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="grid gap-4">
<div className="grid items-center gap-4">

View File

@@ -1,18 +1,18 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
@@ -21,213 +21,218 @@ import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
const addResourcesRedis = z.object({
memoryReservation: z.number().nullable().optional(),
cpuLimit: z.number().nullable().optional(),
memoryLimit: z.number().nullable().optional(),
cpuReservation: z.number().nullable().optional(),
memoryReservation: z.number().nullable().optional(),
cpuLimit: z.number().nullable().optional(),
memoryLimit: z.number().nullable().optional(),
cpuReservation: z.number().nullable().optional(),
});
interface Props {
redisId: string;
redisId: string;
}
type AddResourcesRedis = z.infer<typeof addResourcesRedis>;
export const ShowRedisResources = ({ redisId }: Props) => {
const { data, refetch } = api.redis.one.useQuery(
{
redisId,
},
{ enabled: !!redisId },
);
const { mutateAsync, isLoading } = api.redis.update.useMutation();
const form = useForm<AddResourcesRedis>({
defaultValues: {},
resolver: zodResolver(addResourcesRedis),
});
const { data, refetch } = api.redis.one.useQuery(
{
redisId,
},
{ enabled: !!redisId },
);
const { mutateAsync, isLoading } = api.redis.update.useMutation();
const form = useForm<AddResourcesRedis>({
defaultValues: {},
resolver: zodResolver(addResourcesRedis),
});
useEffect(() => {
if (data) {
form.reset({
cpuLimit: data?.cpuLimit || undefined,
cpuReservation: data?.cpuReservation || undefined,
memoryLimit: data?.memoryLimit || undefined,
memoryReservation: data?.memoryReservation || undefined,
});
}
}, [data, form, form.formState.isSubmitSuccessful, form.reset]);
useEffect(() => {
if (data) {
form.reset({
cpuLimit: data?.cpuLimit || undefined,
cpuReservation: data?.cpuReservation || undefined,
memoryLimit: data?.memoryLimit || undefined,
memoryReservation: data?.memoryReservation || undefined,
});
}
}, [data, form, form.formState.isSubmitSuccessful, form.reset]);
const onSubmit = async (formData: AddResourcesRedis) => {
await mutateAsync({
redisId,
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 === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
const onSubmit = async (formData: AddResourcesRedis) => {
await mutateAsync({
redisId,
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">
<AlertBlock type="info">
Please remember to click Redeploy after modify the resources to apply
the changes.
</AlertBlock>
<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 === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<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 === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
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 === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
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}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuReservation"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Reservation</FormLabel>
<FormControl>
<Input
placeholder={"1"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
<FormField
control={form.control}
name="cpuLimit"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Limit</FormLabel>
<FormControl>
<Input
placeholder={"2"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuReservation"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Reservation</FormLabel>
<FormControl>
<Input
placeholder={"1"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Si el campo está vacío, establece el valor como null.
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
// Solo actualiza el valor si se convierte a un número válido.
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
};

View File

@@ -1,13 +1,13 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
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";
@@ -16,47 +16,47 @@ import { useRouter } from "next/router";
import { toast } from "sonner";
interface Props {
redisId: string;
redisId: string;
}
export const DeleteRedis = ({ redisId }: Props) => {
const { mutateAsync, isLoading } = api.redis.remove.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
database
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
redisId,
})
.then((data) => {
push(`/dashboard/project/${data?.projectId}`);
toast.success("Database delete succesfully");
})
.catch(() => {
toast.error("Error to delete the database");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
const { mutateAsync, isLoading } = api.redis.remove.useMutation();
const { push } = useRouter();
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
database
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
redisId,
})
.then((data) => {
push(`/dashboard/project/${data?.projectId}`);
toast.success("Database delete succesfully");
})
.catch(() => {
toast.error("Error to delete the database");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -19,7 +19,7 @@ import {
} from "@/components/ui/form";
import { api } from "@/utils/api";
import { toast } from "sonner";
import { Textarea } from "@/components/ui/textarea";
import { CodeEditor } from "@/components/shared/code-editor";
const addEnvironmentSchema = z.object({
environment: z.string(),
@@ -93,9 +93,12 @@ export const ShowRedisEnvironment = ({ redisId }: Props) => {
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Textarea
placeholder="REDIS_PASSWORD=1234567678"
className="h-96"
<CodeEditor
language="properties"
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono"
{...field}
/>
</FormControl>

View File

@@ -50,17 +50,13 @@ export const DeployRedis = ({ redisId }: Props) => {
applicationStatus: "running",
})
.then(async () => {
toast.success("Database Deploying....");
toast.success("Deploying Database...");
await refetch();
await deploy({
redisId,
})
.then(() => {
toast.success("Database Deployed Succesfully");
})
.catch(() => {
toast.error("Error to deploy Database");
});
}).catch(() => {
toast.error("Error to deploy Database");
});
await refetch();
})
.catch((e) => {

View File

@@ -1,3 +1,4 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -129,7 +130,7 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
<div className="grid w-full gap-8">
<div className="flex flex-col gap-3">
<Label>External Host</Label>
<Input disabled value={connectionUrl} />
<ToggleVisibilityInput value={connectionUrl} disabled />
</div>
</div>
)}

View File

@@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
interface Props {
redisId: string;
@@ -25,10 +26,9 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<Input
disabled
<ToggleVisibilityInput
value={data?.databasePassword}
type="password"
disabled
/>
</div>
</div>
@@ -44,7 +44,7 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
<div className="flex flex-col gap-2 md:col-span-2">
<Label>Internal Connection URL </Label>
<Input
<ToggleVisibilityInput
disabled
value={`redis://default:${data?.databasePassword}@${data?.appName}:6379`}
/>

View File

@@ -18,6 +18,7 @@ import {
} from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { toast } from "sonner";
@@ -89,7 +90,7 @@ export const UpdateRedis = ({ redisId }: Props) => {
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost">
<SquarePen className="size-4" />
<SquarePen className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
@@ -97,14 +98,7 @@ export const UpdateRedis = ({ redisId }: Props) => {
<DialogTitle>Modify Redis</DialogTitle>
<DialogDescription>Update the redis 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>
)}
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="grid gap-4">
<div className="grid items-center gap-4">

View File

@@ -10,6 +10,7 @@ import { api } from "@/utils/api";
import { AlertTriangle, Package } from "lucide-react";
import { DeleteVolume } from "../../application/advanced/volumes/delete-volume";
import { AddVolumes } from "../../application/advanced/volumes/add-volumes";
import { UpdateVolume } from "../../application/advanced/volumes/update-volume";
interface Props {
redisId: string;
}
@@ -109,7 +110,12 @@ export const ShowVolumes = ({ redisId }: Props) => {
</span>
</div>
</div>
<div>
<div className="flex flex-row gap-1">
<UpdateVolume
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
/>
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More