Feat/add sidebar (#1084)

* refactor: add sidebar

* chore: add deps

* refactor: update sidebar

* refactor: another layout

* refactor: update variant

* refactor: change layout

* refactor: change variant

* refactor: enhance sidebar navigation with active state management

* feat: add project button to dashboard

* Merge branch 'canary' into feat/add-sidebar

* refactor: add loader

* refactor: update destinations and refactor

* refactor: ui refactor certificates

* refactor: delete unused files

* refactor: remove unused files and duplicate registry

* refactor: update style registry

* refactor: add new design registry

* refactor: enhance git providers

* refactor: remove duplicate files

* refactor: update

* refactor: update users

* refactor: delete unused files

* refactor: update profile

* refactor: apply changes

* refactor: update UI

* refactor: enhance Docker monitoring UI layout

* refactor: add theme toggle and language selection to user navigation (#1083)

* refactor: remove unused files

* feat: add filter to services

* refactor: add active items

* refactor: remove tab prop

* refactor: remove unused files

* refactor: remove duplicated files

* refactor: remove unused files

* refactor: remove duplicate files

* refactor: remove unused files

* refactor: delete unused files

* refactor: remove unsued files

* refactor: delete unused files

* refactor: lint

* refactor: remove unused secuirty

* refactor: delete unused files

* refactor: delete unused files

* remove imports

* refactor: add update button

* refactor: delete unused files

* refactor: remove unused code

* refactor: remove unused files

* refactor: update login page

* refactor: update login UI

* refactor: update ui reset password

* refactor: add justify end

* feat: add suscriptions

* feat: add sheet

* feat: add logs for postgres

* feat: add logs for all databases

* feat: add server logs with drawer logs

* refactor: remove unused files

* refactor: add refetch when closing

* refactor: fix linter

* chore: bump node-20

* revert

* refactor: fix conflicts

* refactor: update

* refactor: add missing deps

* refactor: delete duplicate files

* refactor: delete unsued files

* chore: lint

* refactor: remove unsued file

* refactor: add refetch

* refactor: remove duplicated files

* refactor: delete unused files

* refactor: update setup onboarding

* refactor: add breadcrumb

* refactor: apply updates

* refactor: add faker

* refactor: use 0 in validation

* refactor: show correct state

* refactor: update

---------

Co-authored-by: vishalkadam47 <vishal@jeevops.com>
Co-authored-by: Vishal kadam <107353260+vishalkadam47@users.noreply.github.com>
This commit is contained in:
Mauricio Siu
2025-01-12 14:29:43 -06:00
committed by GitHub
parent 87f4c7b71b
commit a104867ed2
278 changed files with 18706 additions and 16959 deletions

View File

@@ -1,198 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Languages } from "@/lib/languages";
import useLocale from "@/utils/hooks/use-locale";
import { useTranslation } from "next-i18next";
import { useTheme } from "next-themes";
import { useEffect } from "react";
import { toast } from "sonner";
const languageCodes = Object.values(Languages).map(lang => lang.code) as [string, ...string[]];
const appearanceFormSchema = z.object({
theme: z.enum(["light", "dark", "system"], {
required_error: "Please select a theme.",
}),
language: z.enum(languageCodes, {
required_error: "Please select a language.",
})
});
type AppearanceFormValues = z.infer<typeof appearanceFormSchema>;
// This can come from your database or API.
const defaultValues: Partial<AppearanceFormValues> = {
theme: "system",
language: Languages.english.code,
};
export function AppearanceForm() {
const { setTheme, theme } = useTheme();
const { locale, setLocale } = useLocale();
const { t } = useTranslation("settings");
const form = useForm<AppearanceFormValues>({
resolver: zodResolver(appearanceFormSchema),
defaultValues,
});
useEffect(() => {
form.reset({
theme: (theme ?? "system") as AppearanceFormValues["theme"],
language: locale,
});
}, [form, theme, locale]);
function onSubmit(data: AppearanceFormValues) {
setTheme(data.theme);
setLocale(data.language);
toast.success("Preferences Updated");
}
return (
<Card className="bg-transparent">
<CardHeader>
<CardTitle className="text-xl">
{t("settings.appearance.title")}
</CardTitle>
<CardDescription>
{t("settings.appearance.description")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="theme"
defaultValue={form.control._defaultValues.theme}
render={({ field }) => {
return (
<FormItem className="space-y-1 ">
<FormLabel>{t("settings.appearance.theme")}</FormLabel>
<FormDescription>
{t("settings.appearance.themeDescription")}
</FormDescription>
<FormMessage />
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
className="grid max-w-md md:max-w-lg grid-cols-1 sm:grid-cols-3 gap-8 pt-2"
>
<FormItem>
<FormLabel className="[&:has([data-state=checked])>div]:border-primary">
<FormControl>
<RadioGroupItem value="light" className="sr-only" />
</FormControl>
<div className="items-center rounded-md border-2 border-muted p-1 hover:bg-accent transition-colors cursor-pointer">
<img src="/images/theme-light.svg" alt="light" />
</div>
<span className="block w-full p-2 text-center font-normal">
{t("settings.appearance.themes.light")}
</span>
</FormLabel>
</FormItem>
<FormItem>
<FormLabel className="[&:has([data-state=checked])>div]:border-primary">
<FormControl>
<RadioGroupItem value="dark" className="sr-only" />
</FormControl>
<div className="items-center rounded-md border-2 border-muted bg-popover p-1 transition-colors hover:bg-accent hover:text-accent-foreground cursor-pointer">
<img src="/images/theme-dark.svg" alt="dark" />
</div>
<span className="block w-full p-2 text-center font-normal">
{t("settings.appearance.themes.dark")}
</span>
</FormLabel>
</FormItem>
<FormItem>
<FormLabel className="[&:has([data-state=checked])>div]:border-primary">
<FormControl>
<RadioGroupItem
value="system"
className="sr-only"
/>
</FormControl>
<div className="items-center rounded-md border-2 border-muted bg-popover p-1 transition-colors hover:bg-accent hover:text-accent-foreground cursor-pointer">
<img src="/images/theme-system.svg" alt="system" />
</div>
<span className="block w-full p-2 text-center font-normal">
{t("settings.appearance.themes.system")}
</span>
</FormLabel>
</FormItem>
</RadioGroup>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="language"
defaultValue={form.control._defaultValues.language}
render={({ field }) => {
return (
<FormItem className="space-y-1">
<FormLabel>{t("settings.appearance.language")}</FormLabel>
<FormDescription>
{t("settings.appearance.languageDescription")}
</FormDescription>
<FormMessage />
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<SelectTrigger>
<SelectValue placeholder="No preset selected" />
</SelectTrigger>
<SelectContent>
{Object.values(Languages).map((language) => (
<SelectItem key={language.code} value={language.code}>
{language.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
);
}}
/>
<Button type="submit">{t("settings.common.save")}</Button>
</form>
</Form>
</CardContent>
</Card>
);
}

View File

@@ -1,5 +1,12 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { NumberInput } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -10,6 +17,7 @@ import clsx from "clsx";
import {
AlertTriangle,
CheckIcon,
CreditCard,
Loader2,
MinusIcon,
PlusIcon,
@@ -67,223 +75,250 @@ export const ShowBilling = () => {
const safePercentage = Math.min(percentage, 100);
return (
<div className="flex flex-col gap-4 w-full">
<Tabs
defaultValue="monthly"
value={isAnnual ? "annual" : "monthly"}
className="w-full"
onValueChange={(e) => setIsAnnual(e === "annual")}
>
<TabsList>
<TabsTrigger value="monthly">Monthly</TabsTrigger>
<TabsTrigger value="annual">Annual</TabsTrigger>
</TabsList>
</Tabs>
{admin?.stripeSubscriptionId && (
<div className="space-y-2">
<h3 className="text-lg font-medium">Servers Plan</h3>
<p className="text-sm text-muted-foreground">
You have {servers?.length} server on your plan of{" "}
{admin?.serversQuantity} servers
</p>
<div className="pb-5">
<Progress value={safePercentage} className="max-w-lg" />
</div>
{admin && admin.serversQuantity! <= servers?.length! && (
<div className="flex flex-row gap-4 p-2 bg-yellow-50 dark:bg-yellow-950 rounded-lg items-center">
<AlertTriangle className="text-yellow-600 dark:text-yellow-400" />
<span className="text-sm text-yellow-600 dark:text-yellow-400">
You have reached the maximum number of servers you can create,
please upgrade your plan to add more servers.
</span>
</div>
)}
</div>
)}
<div className="flex flex-col gap-1.5 mt-4">
<span className="text-base text-primary">
Need Help? We are here to help you.
</span>
<span className="text-sm text-muted-foreground">
Join to our Discord server and we will help you.
</span>
<Button className="rounded-full bg-[#5965F2] hover:bg-[#4A55E0] w-fit">
<Link
href="https://discord.gg/2tBnJ3jDJc"
aria-label="Dokploy on GitHub"
target="_blank"
className="flex flex-row items-center gap-2 text-white"
>
<svg
role="img"
className="h-6 w-6 fill-white"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
Join Discord
</Link>
</Button>
</div>
{isLoading ? (
<span className="text-base text-muted-foreground flex flex-row gap-3 items-center justify-center min-h-[10vh]">
Loading...
<Loader2 className="animate-spin" />
</span>
) : (
<>
{products?.map((product) => {
const featured = true;
return (
<div key={product.id}>
<section
className={clsx(
"flex flex-col rounded-3xl border-dashed border-2 px-4 max-w-sm",
featured
? "order-first border py-8 lg:order-none"
: "lg:py-8",
)}
>
{isAnnual && (
<div className="mb-4 flex flex-row items-center gap-2">
<Badge>Recommended 🚀</Badge>
</div>
)}
{isAnnual ? (
<div className="flex flex-row gap-2 items-center">
<p className=" text-2xl font-semibold tracking-tight text-primary ">
$ {calculatePrice(serverQuantity, isAnnual).toFixed(2)}{" "}
USD
</p>
|
<p className=" text-base font-semibold tracking-tight text-muted-foreground">
${" "}
{(
calculatePrice(serverQuantity, isAnnual) / 12
).toFixed(2)}{" "}
/ Month USD
</p>
</div>
) : (
<p className=" text-2xl font-semibold tracking-tight text-primary ">
$ {calculatePrice(serverQuantity, isAnnual).toFixed(2)}{" "}
USD
</p>
)}
<h3 className="mt-5 font-medium text-lg text-primary">
{product.name}
</h3>
<p
className={clsx(
"text-sm",
featured ? "text-white" : "text-slate-400",
)}
>
{product.description}
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<CreditCard className="size-6 text-muted-foreground self-center" />
Billing
</CardTitle>
<CardDescription>Manage your subscription</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
<div className="flex flex-col gap-4 w-full">
<Tabs
defaultValue="monthly"
value={isAnnual ? "annual" : "monthly"}
className="w-full"
onValueChange={(e) => setIsAnnual(e === "annual")}
>
<TabsList>
<TabsTrigger value="monthly">Monthly</TabsTrigger>
<TabsTrigger value="annual">Annual</TabsTrigger>
</TabsList>
</Tabs>
{admin?.stripeSubscriptionId && (
<div className="space-y-2 flex flex-col">
<h3 className="text-lg font-medium">Servers Plan</h3>
<p className="text-sm text-muted-foreground">
You have {servers?.length} server on your plan of{" "}
{admin?.serversQuantity} servers
</p>
<ul
className={clsx(
" mt-4 flex flex-col gap-y-2 text-sm",
featured ? "text-white" : "text-slate-200",
)}
>
{[
"All the features of Dokploy",
"Unlimited deployments",
"Self-hosted on your own infrastructure",
"Full access to all deployment features",
"Dokploy integration",
"Backups",
"All Incoming features",
].map((feature) => (
<li key={feature} className="flex text-muted-foreground">
<CheckIcon />
<span className="ml-4">{feature}</span>
</li>
))}
</ul>
<div className="flex flex-col gap-2 mt-4">
<div className="flex items-center gap-2 justify-center">
<span className="text-sm text-muted-foreground">
{serverQuantity} Servers
<div>
<Progress value={safePercentage} className="max-w-lg" />
</div>
{admin && admin.serversQuantity! <= servers?.length! && (
<div className="flex flex-row gap-4 p-2 bg-yellow-50 dark:bg-yellow-950 rounded-lg items-center">
<AlertTriangle className="text-yellow-600 dark:text-yellow-400" />
<span className="text-sm text-yellow-600 dark:text-yellow-400">
You have reached the maximum number of servers you can
create, please upgrade your plan to add more servers.
</span>
</div>
<div className="flex items-center space-x-2">
<Button
disabled={serverQuantity <= 1}
variant="outline"
onClick={() => {
if (serverQuantity <= 1) return;
setServerQuantity(serverQuantity - 1);
}}
>
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={serverQuantity}
onChange={(e) => {
setServerQuantity(
e.target.value as unknown as number,
);
}}
/>
<Button
variant="outline"
onClick={() => {
setServerQuantity(serverQuantity + 1);
}}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
<div
className={cn(
data?.subscriptions && data?.subscriptions?.length > 0
? "justify-between"
: "justify-end",
"flex flex-row items-center gap-2 mt-4",
)}
)}
</div>
)}
<div className="flex flex-col gap-1.5 mt-4">
<span className="text-base text-primary">
Need Help? We are here to help you.
</span>
<span className="text-sm text-muted-foreground">
Join to our Discord server and we will help you.
</span>
<Button className="rounded-full bg-[#5965F2] hover:bg-[#4A55E0] w-fit">
<Link
href="https://discord.gg/2tBnJ3jDJc"
aria-label="Dokploy on GitHub"
target="_blank"
className="flex flex-row items-center gap-2 text-white"
>
<svg
role="img"
className="h-6 w-6 fill-white"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
{admin?.stripeCustomerId && (
<Button
variant="secondary"
className="w-full"
onClick={async () => {
const session = await createCustomerPortalSession();
window.open(session.url);
}}
>
Manage Subscription
</Button>
)}
{data?.subscriptions?.length === 0 && (
<div className="justify-end w-full">
<Button
className="w-full"
onClick={async () => {
handleCheckout(product.id);
}}
disabled={serverQuantity < 1}
>
Subscribe
</Button>
</div>
)}
</div>
</div>
</section>
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
Join Discord
</Link>
</Button>
</div>
);
})}
</>
)}
{isLoading ? (
<span className="text-base text-muted-foreground flex flex-row gap-3 items-center justify-center min-h-[10vh]">
Loading...
<Loader2 className="animate-spin" />
</span>
) : (
<>
{products?.map((product) => {
const featured = true;
return (
<div key={product.id}>
<section
className={clsx(
"flex flex-col rounded-3xl border-dashed border-2 px-4 max-w-sm",
featured
? "order-first border py-8 lg:order-none"
: "lg:py-8",
)}
>
{isAnnual && (
<div className="mb-4 flex flex-row items-center gap-2">
<Badge>Recommended 🚀</Badge>
</div>
)}
{isAnnual ? (
<div className="flex flex-row gap-2 items-center">
<p className=" text-2xl font-semibold tracking-tight text-primary ">
${" "}
{calculatePrice(
serverQuantity,
isAnnual,
).toFixed(2)}{" "}
USD
</p>
|
<p className=" text-base font-semibold tracking-tight text-muted-foreground">
${" "}
{(
calculatePrice(serverQuantity, isAnnual) / 12
).toFixed(2)}{" "}
/ Month USD
</p>
</div>
) : (
<p className=" text-2xl font-semibold tracking-tight text-primary ">
${" "}
{calculatePrice(serverQuantity, isAnnual).toFixed(
2,
)}{" "}
USD
</p>
)}
<h3 className="mt-5 font-medium text-lg text-primary">
{product.name}
</h3>
<p
className={clsx(
"text-sm",
featured ? "text-white" : "text-slate-400",
)}
>
{product.description}
</p>
<ul
className={clsx(
" mt-4 flex flex-col gap-y-2 text-sm",
featured ? "text-white" : "text-slate-200",
)}
>
{[
"All the features of Dokploy",
"Unlimited deployments",
"Self-hosted on your own infrastructure",
"Full access to all deployment features",
"Dokploy integration",
"Backups",
"All Incoming features",
].map((feature) => (
<li
key={feature}
className="flex text-muted-foreground"
>
<CheckIcon />
<span className="ml-4">{feature}</span>
</li>
))}
</ul>
<div className="flex flex-col gap-2 mt-4">
<div className="flex items-center gap-2 justify-center">
<span className="text-sm text-muted-foreground">
{serverQuantity} Servers
</span>
</div>
<div className="flex items-center space-x-2">
<Button
disabled={serverQuantity <= 1}
variant="outline"
onClick={() => {
if (serverQuantity <= 1) return;
setServerQuantity(serverQuantity - 1);
}}
>
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={serverQuantity}
onChange={(e) => {
setServerQuantity(
e.target.value as unknown as number,
);
}}
/>
<Button
variant="outline"
onClick={() => {
setServerQuantity(serverQuantity + 1);
}}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
<div
className={cn(
data?.subscriptions &&
data?.subscriptions?.length > 0
? "justify-between"
: "justify-end",
"flex flex-row items-center gap-2 mt-4",
)}
>
{admin?.stripeCustomerId && (
<Button
variant="secondary"
className="w-full"
onClick={async () => {
const session =
await createCustomerPortalSession();
window.open(session.url);
}}
>
Manage Subscription
</Button>
)}
{data?.subscriptions?.length === 0 && (
<div className="justify-end w-full">
<Button
className="w-full"
onClick={async () => {
handleCheckout(product.id);
}}
disabled={serverQuantity < 1}
>
Subscribe
</Button>
</div>
)}
</div>
</div>
</section>
</div>
);
})}
</>
)}
</div>
</CardContent>
</div>
</Card>
</div>
);
};

View File

@@ -30,14 +30,13 @@ import {
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, HelpCircle } from "lucide-react";
import { useEffect } from "react";
import { HelpCircle, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -59,6 +58,7 @@ const addCertificate = z.object({
type AddCertificate = z.infer<typeof addCertificate>;
export const AddCertificate = () => {
const [open, setOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, isError, error, isLoading } =
@@ -89,20 +89,27 @@ export const AddCertificate = () => {
.then(async () => {
toast.success("Certificate Created");
await utils.certificates.all.invalidate();
setOpen(false);
})
.catch(() => {
toast.error("Error creating the Certificate");
});
};
return (
<Dialog>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="" asChild>
<Button>Add Certificate</Button>
<Button>
{" "}
<PlusIcon className="h-4 w-4" />
Add Certificate
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Add Certificate</DialogTitle>
<DialogDescription>Add a new certificate</DialogDescription>
<DialogTitle>Add New Certificate</DialogTitle>
<DialogDescription>
Upload or generate a certificate to secure your application
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
@@ -208,7 +215,7 @@ export const AddCertificate = () => {
/>
</form>
<DialogFooter className="flex w-full flex-row !justify-between pt-3">
<DialogFooter className="flex w-full flex-row !justify-end pt-3">
<Button
isLoading={isLoading}
form="hook-form-add-certificate"

View File

@@ -1,62 +0,0 @@
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 React from "react";
import { toast } from "sonner";
interface Props {
certificateId: string;
}
export const DeleteCertificate = ({ certificateId }: Props) => {
const { mutateAsync, isLoading } = api.certificates.remove.useMutation();
const utils = api.useUtils();
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
certificate.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
certificateId,
})
.then(async () => {
utils.certificates.all.invalidate();
utils.auth.get.invalidate();
toast.success("Certificate deleted successfully");
})
.catch(() => {
toast.error("Error deleting Certificate");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,3 +1,6 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
@@ -6,220 +9,145 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { AlertCircle, Link, ShieldCheck } from "lucide-react";
import { AlertCircle, Link, Loader2, ShieldCheck, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { AddCertificate } from "./add-certificate";
import { DeleteCertificate } from "./delete-certificate";
import { getCertificateChainInfo, getExpirationStatus } from "./utils";
export const ShowCertificates = () => {
const { data } = api.certificates.all.useQuery();
const extractExpirationDate = (certData: string): Date | null => {
try {
const match = certData.match(
/-----BEGIN CERTIFICATE-----\s*([^-]+)\s*-----END CERTIFICATE-----/,
);
if (!match?.[1]) return null;
const base64Cert = match[1].replace(/\s/g, "");
const binaryStr = window.atob(base64Cert);
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i);
}
let dateFound = 0;
for (let i = 0; i < bytes.length - 2; i++) {
if (bytes[i] === 0x17 || bytes[i] === 0x18) {
const dateType = bytes[i];
const dateLength = bytes[i + 1];
if (typeof dateLength === "undefined") continue;
if (dateFound === 0) {
dateFound++;
i += dateLength + 1;
continue;
}
let dateStr = "";
for (let j = 0; j < dateLength; j++) {
const charCode = bytes[i + 2 + j];
if (typeof charCode === "undefined") continue;
dateStr += String.fromCharCode(charCode);
}
if (dateType === 0x17) {
// UTCTime (YYMMDDhhmmssZ)
const year = Number.parseInt(dateStr.slice(0, 2));
const fullYear = year >= 50 ? 1900 + year : 2000 + year;
return new Date(
Date.UTC(
fullYear,
Number.parseInt(dateStr.slice(2, 4)) - 1,
Number.parseInt(dateStr.slice(4, 6)),
Number.parseInt(dateStr.slice(6, 8)),
Number.parseInt(dateStr.slice(8, 10)),
Number.parseInt(dateStr.slice(10, 12)),
),
);
}
// GeneralizedTime (YYYYMMDDhhmmssZ)
return new Date(
Date.UTC(
Number.parseInt(dateStr.slice(0, 4)),
Number.parseInt(dateStr.slice(4, 6)) - 1,
Number.parseInt(dateStr.slice(6, 8)),
Number.parseInt(dateStr.slice(8, 10)),
Number.parseInt(dateStr.slice(10, 12)),
Number.parseInt(dateStr.slice(12, 14)),
),
);
}
}
return null;
} catch (error) {
console.error("Error parsing certificate:", error);
return null;
}
};
const getExpirationStatus = (certData: string) => {
const expirationDate = extractExpirationDate(certData);
if (!expirationDate)
return {
status: "unknown" as const,
className: "text-muted-foreground",
message: "Could not determine expiration",
};
const now = new Date();
const daysUntilExpiration = Math.ceil(
(expirationDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
);
if (daysUntilExpiration < 0) {
return {
status: "expired" as const,
className: "text-red-500",
message: `Expired on ${expirationDate.toLocaleDateString([], {
year: "numeric",
month: "long",
day: "numeric",
})}`,
};
}
if (daysUntilExpiration <= 30) {
return {
status: "warning" as const,
className: "text-yellow-500",
message: `Expires in ${daysUntilExpiration} days`,
};
}
return {
status: "valid" as const,
className: "text-muted-foreground",
message: `Expires ${expirationDate.toLocaleDateString([], {
year: "numeric",
month: "long",
day: "numeric",
})}`,
};
};
const getCertificateChainInfo = (certData: string) => {
const certCount = (certData.match(/-----BEGIN CERTIFICATE-----/g) || [])
.length;
return certCount > 1
? {
isChain: true,
count: certCount,
}
: {
isChain: false,
count: 1,
};
};
const { mutateAsync, isLoading: isRemoving } =
api.certificates.remove.useMutation();
const { data, isLoading, refetch } = api.certificates.all.useQuery();
return (
<div className="h-full">
<Card className="bg-transparent h-full">
<CardHeader>
<CardTitle className="text-xl">Certificates</CardTitle>
<CardDescription>
Add custom certificates to your application.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 pt-4 h-full">
{!data?.length ? (
<div className="flex flex-col items-center gap-3">
<ShieldCheck className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground">
To create a certificate it is required to upload an existing
certificate
</span>
<AddCertificate />
</div>
) : (
<div className="flex flex-col gap-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{data.map((certificate, index) => {
const expiration = getExpirationStatus(
certificate.certificateData,
);
const chainInfo = getCertificateChainInfo(
certificate.certificateData,
);
return (
<div
key={certificate.certificateId}
className="flex flex-col border p-4 rounded-lg space-y-2"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{index + 1}. {certificate.name}
</span>
{chainInfo.isChain && (
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50">
<Link className="size-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
Chain ({chainInfo.count})
</span>
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<ShieldCheck className="size-6 text-muted-foreground self-center" />
Certificates
</CardTitle>
<CardDescription>
Create certificates in the Traefik directory
</CardDescription>
<AlertBlock type="warning">
Certificates are created in the Traefik directory. Traefik uses
these certificates to secure your applications. Using invalid
certificates can break your Traefik instance, preventing access to
your applications.
</AlertBlock>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<ShieldCheck className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground">
You don't have any certificates created
</span>
<AddCertificate />
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="flex flex-col gap-4 rounded-lg ">
{data?.map((certificate, index) => {
const expiration = getExpirationStatus(
certificate.certificateData,
);
const chainInfo = getCertificateChainInfo(
certificate.certificateData,
);
return (
<div
key={certificate.certificateId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between">
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium">
{index + 1}. {certificate.name}
</span>
{chainInfo.isChain && (
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50">
<Link className="size-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
Chain ({chainInfo.count})
</span>
</div>
)}
<div
className={`text-xs flex items-center gap-1.5 ${expiration.className}`}
>
{expiration.status !== "valid" && (
<AlertCircle className="size-3" />
)}
{expiration.message}
{certificate.autoRenew &&
expiration.status !== "valid" && (
<span className="text-xs text-emerald-500 ml-1">
(Auto-renewal enabled)
</span>
)}
</div>
</div>
</div>
<div className="flex flex-row gap-1">
<DialogAction
title="Delete Certificate"
description="Are you sure you want to delete this certificate?"
type="destructive"
onClick={async () => {
await mutateAsync({
certificateId: certificate.certificateId,
})
.then(() => {
toast.success(
"Certificate deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting certificate",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
)}
</div>
<DeleteCertificate
certificateId={certificate.certificateId}
/>
</div>
<div
className={`text-xs flex items-center gap-1.5 ${expiration.className}`}
>
{expiration.status !== "valid" && (
<AlertCircle className="size-3" />
)}
{expiration.message}
{certificate.autoRenew &&
expiration.status !== "valid" && (
<span className="text-xs text-emerald-500 ml-1">
(Auto-renewal enabled)
</span>
)}
</div>
</div>
);
})}
</div>
);
})}
</div>
<div>
<AddCertificate />
</div>
</div>
)}
</CardContent>
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<AddCertificate />
</div>
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);

View File

@@ -0,0 +1,130 @@
export const extractExpirationDate = (certData: string): Date | null => {
try {
const match = certData.match(
/-----BEGIN CERTIFICATE-----\s*([^-]+)\s*-----END CERTIFICATE-----/,
);
if (!match?.[1]) return null;
const base64Cert = match[1].replace(/\s/g, "");
const binaryStr = window.atob(base64Cert);
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i);
}
let dateFound = 0;
for (let i = 0; i < bytes.length - 2; i++) {
if (bytes[i] === 0x17 || bytes[i] === 0x18) {
const dateType = bytes[i];
const dateLength = bytes[i + 1];
if (typeof dateLength === "undefined") continue;
if (dateFound === 0) {
dateFound++;
i += dateLength + 1;
continue;
}
let dateStr = "";
for (let j = 0; j < dateLength; j++) {
const charCode = bytes[i + 2 + j];
if (typeof charCode === "undefined") continue;
dateStr += String.fromCharCode(charCode);
}
if (dateType === 0x17) {
// UTCTime (YYMMDDhhmmssZ)
const year = Number.parseInt(dateStr.slice(0, 2));
const fullYear = year >= 50 ? 1900 + year : 2000 + year;
return new Date(
Date.UTC(
fullYear,
Number.parseInt(dateStr.slice(2, 4)) - 1,
Number.parseInt(dateStr.slice(4, 6)),
Number.parseInt(dateStr.slice(6, 8)),
Number.parseInt(dateStr.slice(8, 10)),
Number.parseInt(dateStr.slice(10, 12)),
),
);
}
// GeneralizedTime (YYYYMMDDhhmmssZ)
return new Date(
Date.UTC(
Number.parseInt(dateStr.slice(0, 4)),
Number.parseInt(dateStr.slice(4, 6)) - 1,
Number.parseInt(dateStr.slice(6, 8)),
Number.parseInt(dateStr.slice(8, 10)),
Number.parseInt(dateStr.slice(10, 12)),
Number.parseInt(dateStr.slice(12, 14)),
),
);
}
}
return null;
} catch (error) {
console.error("Error parsing certificate:", error);
return null;
}
};
export const getExpirationStatus = (certData: string) => {
const expirationDate = extractExpirationDate(certData);
if (!expirationDate)
return {
status: "unknown" as const,
className: "text-muted-foreground",
message: "Could not determine expiration",
};
const now = new Date();
const daysUntilExpiration = Math.ceil(
(expirationDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
);
if (daysUntilExpiration < 0) {
return {
status: "expired" as const,
className: "text-red-500",
message: `Expired on ${expirationDate.toLocaleDateString([], {
year: "numeric",
month: "long",
day: "numeric",
})}`,
};
}
if (daysUntilExpiration <= 30) {
return {
status: "warning" as const,
className: "text-yellow-500",
message: `Expires in ${daysUntilExpiration} days`,
};
}
return {
status: "valid" as const,
className: "text-muted-foreground",
message: `Expires ${expirationDate.toLocaleDateString([], {
year: "numeric",
month: "long",
day: "numeric",
})}`,
};
};
export const getCertificateChainInfo = (certData: string) => {
const certCount = (certData.match(/-----BEGIN CERTIFICATE-----/g) || [])
.length;
return certCount > 1
? {
isChain: true,
count: certCount,
}
: {
isChain: false,
count: 1,
};
};

View File

@@ -1,4 +1,5 @@
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -11,6 +12,7 @@ import {
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
@@ -30,133 +32,172 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { HelpCircle, LockIcon, MoreHorizontal } from "lucide-react";
import { Boxes, HelpCircle, LockIcon, MoreHorizontal } from "lucide-react";
import React from "react";
import { toast } from "sonner";
import { AddNode } from "./add-node";
import { ShowNodeData } from "./show-node-data";
import { DeleteWorker } from "./workers/delete-worker";
export const ShowNodes = () => {
const { data, isLoading } = api.cluster.getNodes.useQuery();
const { data, isLoading, refetch } = api.cluster.getNodes.useQuery();
const { data: registry } = api.registry.all.useQuery();
const { mutateAsync: deleteNode, isLoading: isRemoving } =
api.cluster.removeWorker.useMutation();
const haveAtLeastOneRegistry = !!(registry && registry?.length > 0);
return (
<Card className="bg-transparent h-full">
<CardHeader className="flex flex-row gap-2 justify-between w-full items-center flex-wrap">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl">Cluster</CardTitle>
<CardDescription>Add nodes to your cluster</CardDescription>
</div>
{haveAtLeastOneRegistry && (
<div className="flex flex-row gap-2">
<AddNode />
</div>
)}
</CardHeader>
<CardContent className="flex flex-col gap-4">
{haveAtLeastOneRegistry ? (
<div className="grid md:grid-cols-1 gap-4">
{isLoading && <div>Loading...</div>}
<Table>
<TableCaption>A list of your managers / workers.</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Hostname</TableHead>
<TableHead className="text-right">Status</TableHead>
<TableHead className="text-right">Role</TableHead>
<TableHead className="text-right">Availability</TableHead>
<TableHead className="text-right">Engine Version</TableHead>
<TableHead className="text-right">Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.map((node) => {
const isManager = node.Spec.Role === "manager";
return (
<TableRow key={node.ID}>
<TableCell className="w-[100px]">
{node.Description.Hostname}
</TableCell>
<TableCell className="text-right">
{node.Status.State}
</TableCell>
<TableCell className="text-right">
<Badge variant={isManager ? "default" : "secondary"}>
{node?.Spec?.Role}
</Badge>
</TableCell>
<TableCell className="text-right">
{node.Spec.Availability}
</TableCell>
<TableCell className="text-right">
{node?.Description.Engine.EngineVersion}
</TableCell>
<TableCell className="text-right">
<DateTooltip date={node.CreatedAt} className="text-sm">
Created{" "}
</DateTooltip>
</TableCell>
<TableCell className="text-right flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<ShowNodeData data={node} />
{!node?.ManagerStatus?.Leader && (
<DeleteWorker nodeId={node.ID} />
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
) : (
<div className="flex flex-col items-center gap-3">
<LockIcon className="size-8 text-muted-foreground" />
<div className="flex flex-row gap-2">
<span className="text-base text-muted-foreground ">
To add nodes to your cluster, you need to configure at least one
registry.
</span>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger className="self-center">
<HelpCircle className="size-5 text-muted-foreground " />
</TooltipTrigger>
<TooltipContent>
Nodes need a registry to pull images from.
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 justify-between w-full items-center flex-wrap">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl flex flex-row gap-2">
<Boxes className="size-6 text-muted-foreground self-center" />
Cluster
</CardTitle>
<CardDescription>Add nodes to your cluster</CardDescription>
</div>
{haveAtLeastOneRegistry && (
<div className="flex flex-row gap-2">
<AddNode />
</div>
)}
</CardHeader>
<CardContent className="space-y-2 py-8 border-t min-h-[35vh]">
{haveAtLeastOneRegistry ? (
<div className="grid md:grid-cols-1 gap-4">
{isLoading && <div>Loading...</div>}
<Table>
<TableCaption>
A list of your managers / workers.
</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Hostname</TableHead>
<TableHead className="text-right">Status</TableHead>
<TableHead className="text-right">Role</TableHead>
<TableHead className="text-right">Availability</TableHead>
<TableHead className="text-right">
Engine Version
</TableHead>
<TableHead className="text-right">Created</TableHead>
<ul className="list-disc list-inside text-sm text-muted-foreground border p-4 rounded-lg flex flex-col gap-1.5 mt-2.5">
<li>
<strong>Docker Registry:</strong> Use custom registries like
Docker Hub, DigitalOcean Registry, etc.
</li>
<li>
<strong>Self-Hosted Docker Registry:</strong> Automatically set
up a local registry to store all images.
</li>
</ul>
</div>
)}
</CardContent>
</Card>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.map((node) => {
const isManager = node.Spec.Role === "manager";
return (
<TableRow key={node.ID}>
<TableCell className="w-[100px]">
{node.Description.Hostname}
</TableCell>
<TableCell className="text-right">
{node.Status.State}
</TableCell>
<TableCell className="text-right">
<Badge
variant={isManager ? "default" : "secondary"}
>
{node?.Spec?.Role}
</Badge>
</TableCell>
<TableCell className="text-right">
{node.Spec.Availability}
</TableCell>
<TableCell className="text-right">
{node?.Description.Engine.EngineVersion}
</TableCell>
<TableCell className="text-right">
<DateTooltip
date={node.CreatedAt}
className="text-sm"
>
Created{" "}
</DateTooltip>
</TableCell>
<TableCell className="text-right flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<ShowNodeData data={node} />
{node?.ManagerStatus?.Leader && (
<DialogAction
title="Delete Node"
description="Are you sure you want to delete this node from the cluster?"
type="destructive"
onClick={async () => {
await deleteNode({
nodeId: node.ID,
})
.then(() => {
refetch();
toast.success(
"Node deleted successfully",
);
})
.catch(() => {
toast.error("Error deleting node");
});
}}
>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
>
Delete
</DropdownMenuItem>
</DialogAction>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
) : (
<div className="flex flex-col items-center gap-3">
<LockIcon className="size-8 text-muted-foreground" />
<div className="flex flex-row gap-2">
<span className="text-base text-muted-foreground ">
To add nodes to your cluster, you need to configure at least
one registry.
</span>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger className="self-center">
<HelpCircle className="size-5 text-muted-foreground " />
</TooltipTrigger>
<TooltipContent>
Nodes need a registry to pull images from.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<ul className="list-disc list-inside text-sm text-muted-foreground border p-4 rounded-lg flex flex-col gap-1.5 mt-2.5">
<li>
<strong>Docker Registry:</strong> Use custom registries like
Docker Hub, DigitalOcean Registry, etc.
</li>
</ul>
</div>
)}
</CardContent>
</div>
</Card>
</div>
);
};

View File

@@ -1,62 +0,0 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { api } from "@/utils/api";
import { TrashIcon } from "lucide-react";
import React from "react";
import { toast } from "sonner";
interface Props {
nodeId: string;
}
export const DeleteWorker = ({ nodeId }: Props) => {
const { mutateAsync, isLoading } = api.cluster.removeWorker.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
Delete
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the
worker.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
nodeId,
})
.then(async () => {
utils.cluster.getNodes.invalidate();
toast.success("Worker deleted successfully");
})
.catch(() => {
toast.error("Error deleting Worker");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,61 +0,0 @@
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 React from "react";
import { toast } from "sonner";
interface Props {
registryId: string;
}
export const DeleteRegistry = ({ registryId }: Props) => {
const { mutateAsync, isLoading } = api.registry.remove.useMutation();
const utils = api.useUtils();
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
registry.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
registryId,
})
.then(async () => {
utils.registry.all.invalidate();
toast.success("Registry deleted");
})
.catch(() => {
toast.error("Error deleting the registry");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -11,6 +11,7 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@@ -28,7 +29,7 @@ import {
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, Container } from "lucide-react";
import { AlertTriangle, PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -51,13 +52,35 @@ const AddRegistrySchema = z.object({
type AddRegistry = z.infer<typeof AddRegistrySchema>;
export const AddRegistry = () => {
interface Props {
registryId?: string;
}
export const HandleRegistry = ({ registryId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, error, isError } = api.registry.create.useMutation();
const { data: registry } = api.registry.one.useQuery(
{
registryId: registryId || "",
},
{
enabled: !!registryId,
},
);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { mutateAsync, error, isError } = registryId
? api.registry.update.useMutation()
: api.registry.create.useMutation();
const { data: servers } = api.server.withSSHKey.useQuery();
const { mutateAsync: testRegistry, isLoading } =
api.registry.testRegistry.useMutation();
const {
mutateAsync: testRegistry,
isLoading,
error: testRegistryError,
isError: testRegistryIsError,
} = api.registry.testRegistry.useMutation();
const form = useForm<AddRegistry>({
defaultValues: {
username: "",
@@ -78,14 +101,24 @@ export const AddRegistry = () => {
const serverId = form.watch("serverId");
useEffect(() => {
form.reset({
username: "",
password: "",
registryUrl: "",
imagePrefix: "",
serverId: "",
});
}, [form, form.reset, form.formState.isSubmitSuccessful]);
if (registry) {
form.reset({
username: registry.username,
password: "",
registryUrl: registry.registryUrl,
imagePrefix: registry.imagePrefix || "",
registryName: registry.registryName,
});
} else {
form.reset({
username: "",
password: "",
registryUrl: "",
imagePrefix: "",
serverId: "",
});
}
}, [form, form.reset, form.formState.isSubmitSuccessful, registry]);
const onSubmit = async (data: AddRegistry) => {
await mutateAsync({
@@ -96,44 +129,57 @@ export const AddRegistry = () => {
registryType: "cloud",
imagePrefix: data.imagePrefix,
serverId: data.serverId,
registryId: registryId || "",
})
.then(async (data) => {
await utils.registry.all.invalidate();
toast.success("Registry added");
toast.success(registryId ? "Registry updated" : "Registry added");
setIsOpen(false);
})
.catch(() => {
toast.error("Error adding a registry");
toast.error(
registryId ? "Error updating a registry" : "Error adding a registry",
);
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button className="max-sm:w-full">
<Container className="h-4 w-4" />
Create Registry
</Button>
{registryId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button className="cursor-pointer space-x-3">
<PlusIcon className="h-4 w-4" />
Add Registry
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:m:max-w-lg ">
<DialogContent className="sm:max-w-2xl max-h-screen overflow-y-auto">
<DialogHeader>
<DialogTitle>Add a external registry</DialogTitle>
<DialogDescription>
Fill the next fields to add a external registry.
</DialogDescription>
</DialogHeader>
{isError && (
{(isError || testRegistryIsError) && (
<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}
{testRegistryError?.message || error?.message || ""}
</span>
</div>
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
className="grid grid-cols-1 sm:grid-cols-2 w-full gap-4"
>
<div className="flex flex-col gap-4">
<FormField
@@ -208,7 +254,7 @@ export const AddRegistry = () => {
)}
/>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 col-span-2">
<FormField
control={form.control}
name="registryUrl"
@@ -227,52 +273,75 @@ export const AddRegistry = () => {
)}
/>
</div>
<DialogFooter className="flex flex-col w-full sm:justify-between gap-4 flex-wrap sm:flex-col">
<div className="flex flex-col gap-4 border p-2 rounded-lg">
<span className="text-sm text-muted-foreground">
Select a server to test the registry. If you don't have a
server choose the default one.
</span>
<FormField
control={form.control}
name="serverId"
render={({ field }) => (
<FormItem>
<FormLabel>Server (Optional)</FormLabel>
<FormControl>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a server" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Servers</SelectLabel>
{servers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
<SelectItem value={"none"}>None</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="col-span-2">
<FormField
control={form.control}
name="serverId"
render={({ field }) => (
<FormItem>
<FormLabel>Server {!isCloud && "(Optional)"}</FormLabel>
<FormDescription>
Select a server to test the registry. this will run the
following command on the server
</FormDescription>
<FormControl>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a server" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Servers</SelectLabel>
{servers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
<SelectItem value={"none"}>None</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter className="flex flex-col w-full sm:justify-between gap-4 flex-wrap sm:flex-col col-span-2 mt-6">
<div className="flex flex-row gap-2 justify-between">
<Button
type="button"
variant={"secondary"}
isLoading={isLoading}
onClick={async () => {
const validationResult = AddRegistrySchema.safeParse({
username,
password,
registryUrl,
registryName: "Dokploy Registry",
imagePrefix,
serverId,
});
if (!validationResult.success) {
for (const issue of validationResult.error.issues) {
form.setError(issue.path[0] as any, {
type: "manual",
message: issue.message,
});
}
return;
}
await testRegistry({
username: username,
password: password,
@@ -296,11 +365,10 @@ export const AddRegistry = () => {
>
Test Registry
</Button>
<Button isLoading={form.formState.isSubmitting} type="submit">
{registryId ? "Update" : "Create"}
</Button>
</div>
<Button isLoading={form.formState.isSubmitting} type="submit">
Create
</Button>
</DialogFooter>
</form>
</Form>

View File

@@ -1,3 +1,5 @@
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
@@ -6,62 +8,116 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { Server } from "lucide-react";
import { AddRegistry } from "./add-docker-registry";
import { DeleteRegistry } from "./delete-registry";
import { UpdateDockerRegistry } from "./update-docker-registry";
import { Loader2, Package, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { HandleRegistry } from "./handle-registry";
export const ShowRegistry = () => {
const { data } = api.registry.all.useQuery();
const haveSelfHostedRegistry = data?.some(
(registry) => registry.registryType === "selfHosted",
);
const { mutateAsync, isLoading: isRemoving } =
api.registry.remove.useMutation();
const { data, isLoading, refetch } = api.registry.all.useQuery();
return (
<div className="h-full">
<Card className="bg-transparent h-full">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between w-full items-center">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl">Registry</CardTitle>
<CardDescription>Add registry to your application.</CardDescription>
</div>
<div className="flex flex-row gap-2">
{data && data?.length > 0 && <AddRegistry />}
</div>
</CardHeader>
<CardContent className="space-y-2 pt-4 h-full">
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3">
<Server className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
To create a cluster it is required to set a registry.
</span>
<div className="flex flex-row md:flex-row gap-2 flex-wrap w-full justify-center">
<AddRegistry />
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<Package className="size-6 text-muted-foreground self-center" />
Docker Registry
</CardTitle>
<CardDescription>
Manage your Docker Registry configurations
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-1 gap-6">
{data?.map((registry, index) => (
<div
key={registry.registryId}
className="flex items-center justify-between border p-4 rounded-lg"
>
<span className="text-sm text-muted-foreground">
{index + 1}. {registry.registryName}
</span>
<div className="flex flex-row gap-1">
<UpdateDockerRegistry registryId={registry.registryId} />
<DeleteRegistry registryId={registry.registryId} />
) : (
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<Package className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
You don't have any registry configurations
</span>
<HandleRegistry />
</div>
</div>
))}
</div>
)}
</CardContent>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="flex flex-col gap-4 rounded-lg ">
{data?.map((registry, index) => (
<div
key={registry.registryId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between">
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium">
{index + 1}. {registry.registryName}
</span>
{registry.registryUrl && (
<div className="text-xs text-muted-foreground">
{registry.registryUrl}
</div>
)}
</div>
</div>
<div className="flex flex-row gap-1">
<HandleRegistry
registryId={registry.registryId}
/>
<DialogAction
title="Delete Registry"
description="Are you sure you want to delete this registry configuration?"
type="destructive"
onClick={async () => {
await mutateAsync({
registryId: registry.registryId,
})
.then(() => {
toast.success(
"Registry configuration deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting registry configuration",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
</div>
))}
</div>
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleRegistry />
</div>
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);

View File

@@ -1,326 +0,0 @@
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 {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, PenBoxIcon } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const updateRegistry = z.object({
registryName: z.string().min(1, {
message: "Registry name is required",
}),
username: z.string().min(1, {
message: "Username is required",
}),
password: z.string(),
registryUrl: z.string(),
imagePrefix: z.string(),
serverId: z.string().optional(),
});
type UpdateRegistry = z.infer<typeof updateRegistry>;
interface Props {
registryId: string;
}
export const UpdateDockerRegistry = ({ registryId }: Props) => {
const utils = api.useUtils();
const { data: servers } = api.server.withSSHKey.useQuery();
const { mutateAsync: testRegistry, isLoading } =
api.registry.testRegistry.useMutation();
const { data, refetch } = api.registry.one.useQuery(
{
registryId,
},
{
enabled: !!registryId,
},
);
const isCloud = data?.registryType === "cloud";
const { mutateAsync, isError, error } = api.registry.update.useMutation();
const form = useForm<UpdateRegistry>({
defaultValues: {
imagePrefix: "",
registryName: "",
username: "",
password: "",
registryUrl: "",
serverId: "",
},
resolver: zodResolver(updateRegistry),
});
console.log(form.formState.errors);
const password = form.watch("password");
const username = form.watch("username");
const registryUrl = form.watch("registryUrl");
const registryName = form.watch("registryName");
const imagePrefix = form.watch("imagePrefix");
const serverId = form.watch("serverId");
useEffect(() => {
if (data) {
form.reset({
imagePrefix: data.imagePrefix || "",
registryName: data.registryName || "",
username: data.username || "",
password: "",
registryUrl: data.registryUrl || "",
serverId: "",
});
}
}, [form, form.reset, data]);
const onSubmit = async (data: UpdateRegistry) => {
await mutateAsync({
registryId,
...(data.password ? { password: data.password } : {}),
registryName: data.registryName,
username: data.username,
registryUrl: data.registryUrl,
imagePrefix: data.imagePrefix,
serverId: data.serverId,
})
.then(async (data) => {
toast.success("Registry Updated");
await refetch();
await utils.registry.all.invalidate();
})
.catch(() => {
toast.error("Error updating the registry");
});
};
return (
<Dialog>
<DialogTrigger className="" asChild>
<Button variant="ghost">
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Registry</DialogTitle>
<DialogDescription>Update the registry information</DialogDescription>
</DialogHeader>
{isError && (
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{error?.message}
</span>
</div>
)}
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<FormField
control={form.control}
name="registryName"
render={({ field }) => (
<FormItem>
<FormLabel>Registry Name</FormLabel>
<FormControl>
<Input placeholder="Registry Name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="Username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
placeholder="Password"
{...field}
type="password"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isCloud && (
<FormField
control={form.control}
name="imagePrefix"
render={({ field }) => (
<FormItem>
<FormLabel>Image Prefix</FormLabel>
<FormControl>
<Input {...field} placeholder="Image Prefix" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="registryUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Registry URL</FormLabel>
<FormControl>
<Input
placeholder="https://aws_account_id.dkr.ecr.us-west-2.amazonaws.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</form>
<DialogFooter className="flex flex-col w-full sm:justify-between gap-4 flex-wrap sm:flex-col">
<div className="flex flex-col gap-4 border p-2 rounded-lg">
<span className="text-sm text-muted-foreground">
Select a server to test the registry. If you don't have a server
choose the default one.
</span>
<FormField
control={form.control}
name="serverId"
render={({ field }) => (
<FormItem>
<FormLabel>Server (Optional)</FormLabel>
<FormControl>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a server" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Servers</SelectLabel>
{servers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
<SelectItem value={"none"}>None</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant={"secondary"}
isLoading={isLoading}
onClick={async () => {
await testRegistry({
username: username,
password: password,
registryUrl: registryUrl,
registryName: registryName,
registryType: "cloud",
imagePrefix: imagePrefix,
serverId: serverId,
})
.then((data) => {
if (data) {
toast.success("Registry Tested Successfully");
} else {
toast.error("Registry Test Failed");
}
})
.catch(() => {
toast.error("Error testing the registry");
});
}}
>
Test Registry
</Button>
</div>
<Button
isLoading={form.formState.isSubmitting}
type="submit"
form="hook-form"
>
Update
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,61 +0,0 @@
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 React from "react";
import { toast } from "sonner";
interface Props {
destinationId: string;
}
export const DeleteDestination = ({ destinationId }: Props) => {
const { mutateAsync, isLoading } = api.destination.remove.useMutation();
const utils = api.useUtils();
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
destination
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
destinationId,
})
.then(() => {
utils.destination.all.invalidate();
toast.success("Destination deleted successfully");
})
.catch(() => {
toast.error("Error deleting Destination");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -30,7 +30,8 @@ import {
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -49,15 +50,35 @@ const addDestination = z.object({
type AddDestination = z.infer<typeof addDestination>;
export const AddDestination = () => {
interface Props {
destinationId?: string;
}
export const HandleDestinations = ({ destinationId }: Props) => {
const [open, setOpen] = useState(false);
const utils = api.useUtils();
const { data: servers } = api.server.withSSHKey.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { mutateAsync, isError, error, isLoading } =
api.destination.create.useMutation();
const { mutateAsync: testConnection, isLoading: isLoadingConnection } =
api.destination.testConnection.useMutation();
const { mutateAsync, isError, error, isLoading } = destinationId
? api.destination.update.useMutation()
: api.destination.create.useMutation();
const { data: destination } = api.destination.one.useQuery(
{
destinationId: destinationId || "",
},
{
enabled: !!destinationId,
},
);
const {
mutateAsync: testConnection,
isLoading: isLoadingConnection,
error: connectionError,
isError: isErrorConnection,
} = api.destination.testConnection.useMutation();
const form = useForm<AddDestination>({
defaultValues: {
provider: "",
@@ -71,8 +92,20 @@ export const AddDestination = () => {
resolver: zodResolver(addDestination),
});
useEffect(() => {
form.reset();
}, [form, form.reset, form.formState.isSubmitSuccessful]);
if (destination) {
form.reset({
name: destination.name,
provider: destination.provider || "",
accessKeyId: destination.accessKey,
secretAccessKey: destination.secretAccessKey,
bucket: destination.bucket,
region: destination.region,
endpoint: destination.endpoint,
});
} else {
form.reset();
}
}, [form, form.reset, form.formState.isSubmitSuccessful, destination]);
const onSubmit = async (data: AddDestination) => {
await mutateAsync({
@@ -83,28 +116,53 @@ export const AddDestination = () => {
name: data.name,
region: data.region,
secretAccessKey: data.secretAccessKey,
destinationId: destinationId || "",
})
.then(async () => {
toast.success("Destination Created");
toast.success(`Destination ${destinationId ? "Updated" : "Created"}`);
await utils.destination.all.invalidate();
setOpen(false);
})
.catch(() => {
toast.error("Error creating the Destination");
toast.error(
`Error ${destinationId ? "Updating" : "Creating"} the Destination`,
);
});
};
return (
<Dialog>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="" asChild>
<Button>Add Destination</Button>
{destinationId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button className="cursor-pointer space-x-3">
<PlusIcon className="h-4 w-4" />
Add Destination
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Add Destination</DialogTitle>
<DialogTitle>
{destinationId ? "Update" : "Add"} Destination
</DialogTitle>
<DialogDescription>
In this section you can add destinations for your backups.
In this section, you can configure and add new destinations for your
backups. Please ensure that you provide the correct information to
guarantee secure and efficient storage.
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
{(isError || isErrorConnection) && (
<AlertBlock type="error" className="break-words">
{connectionError?.message || error?.message}
</AlertBlock>
)}
<Form {...form}>
<form
@@ -138,6 +196,7 @@ export const AddDestination = () => {
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
@@ -347,7 +406,7 @@ export const AddDestination = () => {
form="hook-form-destination-add"
type="submit"
>
Create
{destinationId ? "Update" : "Create"}
</Button>
</DialogFooter>
</Form>

View File

@@ -1,3 +1,5 @@
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
@@ -6,59 +8,104 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { FolderUp } from "lucide-react";
import { AddDestination } from "./add-destination";
import { DeleteDestination } from "./delete-destination";
import { UpdateDestination } from "./update-destination";
import { Database, FolderUp, Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { HandleDestinations } from "./handle-destinations";
export const ShowDestinations = () => {
const { data } = api.destination.all.useQuery();
const { data, isLoading, refetch } = api.destination.all.useQuery();
const { mutateAsync, isLoading: isRemoving } =
api.destination.remove.useMutation();
return (
<div className="w-full">
<Card className="h-full bg-transparent">
<CardHeader>
<CardTitle className="text-xl">S3 Destinations</CardTitle>
<CardDescription>
Add your providers like AWS S3, Cloudflare R2, Wasabi, DigitalOcean
Spaces etc.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 pt-4">
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3">
<FolderUp className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground">
To create a backup it is required to set at least 1 provider.
</span>
<AddDestination />
</div>
) : (
<div className="flex flex-col gap-4">
{data?.map((destination, index) => (
<div
key={destination.destinationId}
className="flex items-center justify-between border p-3.5 rounded-lg"
>
<span className="text-sm text-muted-foreground">
{index + 1}. {destination.name}
</span>
<div className="flex flex-row gap-1">
<UpdateDestination
destinationId={destination.destinationId}
/>
<DeleteDestination
destinationId={destination.destinationId}
/>
</div>
</div>
))}
<div>
<AddDestination />
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<Database className="size-6 text-muted-foreground self-center" />
S3 Destinations
</CardTitle>
<CardDescription>
Add your providers like AWS S3, Cloudflare R2, Wasabi,
DigitalOcean Spaces etc.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
</div>
)}
</CardContent>
) : (
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<FolderUp className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground">
To create a backup it is required to set at least 1
provider.
</span>
<HandleDestinations />
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="flex flex-col gap-4 rounded-lg ">
{data?.map((destination, index) => (
<div
key={destination.destinationId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<span className="text-sm">
{index + 1}. {destination.name}
</span>
<div className="flex flex-row gap-1">
<HandleDestinations
destinationId={destination.destinationId}
/>
<DialogAction
title="Delete Destination"
description="Are you sure you want to delete this destination?"
type="destructive"
onClick={async () => {
await mutateAsync({
destinationId: destination.destinationId,
})
.then(() => {
toast.success(
"Destination deleted successfully",
);
refetch();
})
.catch(() => {
toast.error("Error deleting destination");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
</div>
))}
</div>
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleDestinations />
</div>
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);

View File

@@ -1,386 +0,0 @@
import { AlertBlock } from "@/components/shared/alert-block";
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 {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { S3_PROVIDERS } from "./constants";
const updateDestination = z.object({
name: z.string().min(1, "Name is required"),
provider: z.string().optional(),
accessKeyId: z.string(),
secretAccessKey: z.string(),
bucket: z.string(),
region: z.string(),
endpoint: z.string(),
serverId: z.string().optional(),
});
type UpdateDestination = z.infer<typeof updateDestination>;
interface Props {
destinationId: string;
}
export const UpdateDestination = ({ destinationId }: Props) => {
const utils = api.useUtils();
const { data: servers } = api.server.withSSHKey.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const [isOpen, setIsOpen] = useState(false);
const { data, refetch } = api.destination.one.useQuery(
{
destinationId,
},
{
enabled: !!destinationId,
},
);
const { mutateAsync, isError, error } = api.destination.update.useMutation();
const { mutateAsync: testConnection, isLoading: isLoadingConnection } =
api.destination.testConnection.useMutation();
const form = useForm<UpdateDestination>({
defaultValues: {
provider: "",
accessKeyId: "",
bucket: "",
name: "",
region: "",
secretAccessKey: "",
endpoint: "",
},
resolver: zodResolver(updateDestination),
});
useEffect(() => {
if (data) {
form.reset({
accessKeyId: data.accessKey,
bucket: data.bucket,
endpoint: data.endpoint,
name: data.name,
region: data.region,
secretAccessKey: data.secretAccessKey,
provider: data.provider || "",
});
}
}, [form, form.reset, data]);
const onSubmit = async (data: UpdateDestination) => {
await mutateAsync({
accessKey: data.accessKeyId,
bucket: data.bucket,
endpoint: data.endpoint,
name: data.name,
region: data.region,
secretAccessKey: data.secretAccessKey,
destinationId,
provider: data.provider || "",
})
.then(async () => {
toast.success("Destination Updated");
await refetch();
await utils.destination.all.invalidate();
setIsOpen(false);
})
.catch(() => {
toast.error("Error updating the Destination");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
<Button variant="ghost">
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Update Destination</DialogTitle>
<DialogDescription>
Update the current destination config
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<FormField
control={form.control}
name="name"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder={"S3 Bucket"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="provider"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Provider</FormLabel>
<FormControl>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a S3 Provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
{S3_PROVIDERS.map((s3Provider) => (
<SelectItem
key={s3Provider.key}
value={s3Provider.key}
>
{s3Provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="accessKeyId"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Access Key Id</FormLabel>
<FormControl>
<Input placeholder={"xcas41dasde"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="secretAccessKey"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Secret Access Key</FormLabel>
</div>
<FormControl>
<Input placeholder={"asd123asdasw"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bucket"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Bucket</FormLabel>
</div>
<FormControl>
<Input placeholder={"dokploy-bucket"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="region"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Region</FormLabel>
</div>
<FormControl>
<Input placeholder={"us-east-1"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint</FormLabel>
<FormControl>
<Input
placeholder={"https://us.bucket.aws/s3"}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</form>
<DialogFooter
className={cn(
isCloud ? "!flex-col" : "flex-row",
"flex w-full !justify-between pt-3 gap-4",
)}
>
{isCloud ? (
<div className="flex flex-col gap-4 border p-2 rounded-lg">
<span className="text-sm text-muted-foreground">
Select a server to test the destination. If you don't have a
server choose the default one.
</span>
<FormField
control={form.control}
name="serverId"
render={({ field }) => (
<FormItem>
<FormLabel>Server (Optional)</FormLabel>
<FormControl>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a server" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Servers</SelectLabel>
{servers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
<SelectItem value={"none"}>None</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant={"secondary"}
onClick={async () => {
await testConnection({
provider: form.getValues("provider") || "",
accessKey: form.getValues("accessKeyId"),
bucket: form.getValues("bucket"),
endpoint: form.getValues("endpoint"),
name: "Test",
region: form.getValues("region"),
secretAccessKey: form.getValues("secretAccessKey"),
serverId: form.getValues("serverId"),
})
.then(async () => {
toast.success("Connection Success");
})
.catch(() => {
toast.error("Error connecting the provider");
});
}}
>
Test Connection
</Button>
</div>
) : (
<Button
isLoading={isLoadingConnection}
type="button"
variant="secondary"
onClick={async () => {
await testConnection({
provider: form.getValues("provider") || "",
accessKey: form.getValues("accessKeyId"),
bucket: form.getValues("bucket"),
endpoint: form.getValues("endpoint"),
name: "Test",
region: form.getValues("region"),
secretAccessKey: form.getValues("secretAccessKey"),
})
.then(async () => {
toast.success("Connection Success");
})
.catch(() => {
toast.error("Error connecting the provider");
});
}}
>
Test connection
</Button>
)}
<Button
form="hook-form"
type="submit"
isLoading={form.formState.isSubmitting}
>
Update
</Button>
</DialogFooter>
</Form>
</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 { Edit } from "lucide-react";
import { Edit, PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -51,6 +51,7 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
enabled: !!bitbucketId,
},
);
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, error, isError } = api.bitbucket.update.useMutation();
@@ -73,7 +74,7 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
workspaceName: bitbucket?.bitbucketWorkspaceName || "",
name: bitbucket?.gitProvider.name || "",
});
}, [form, isOpen]);
}, [form, isOpen, bitbucket]);
const onSubmit = async (data: Schema) => {
await mutateAsync({
@@ -96,14 +97,18 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost">
<Edit className="size-4" />
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl overflow-y-auto max-h-screen">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Update Bitbucket Provider <BitbucketIcon className="size-5" />
Update Bitbucket <BitbucketIcon className="size-5" />
</DialogTitle>
</DialogHeader>
@@ -166,7 +171,7 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
)}
/>
<div className="flex w-full justify-end gap-4 mt-4">
<div className="flex w-full justify-between gap-4 mt-4">
<Button
type="button"
variant={"secondary"}

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 { Edit } from "lucide-react";
import { PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -84,14 +84,18 @@ export const EditGithubProvider = ({ githubId }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost">
<Edit className="size-4" />
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl overflow-y-auto max-h-screen">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Update Github Provider <GithubIcon className="size-5" />
Update Github <GithubIcon className="size-5" />
</DialogTitle>
</DialogHeader>
@@ -121,7 +125,7 @@ export const EditGithubProvider = ({ githubId }: Props) => {
)}
/>
<div className="flex w-full justify-end gap-4 mt-4">
<div className="flex w-full justify-between gap-4 mt-4">
<Button
type="button"
variant={"secondary"}

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 { Edit } from "lucide-react";
import { Edit, PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -97,14 +97,18 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost">
<Edit className="size-4" />
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl overflow-y-auto max-h-screen">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Update GitLab Provider <GitlabIcon className="size-5" />
Update GitLab <GitlabIcon className="size-5" />
</DialogTitle>
</DialogHeader>
@@ -164,7 +168,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
)}
/>
<div className="flex w-full justify-end gap-4 mt-4">
<div className="flex w-full justify-between gap-4 mt-4">
<Button
type="button"
variant={"secondary"}

View File

@@ -1,86 +0,0 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { InfoIcon, TrashIcon } from "lucide-react";
import React from "react";
import { toast } from "sonner";
interface Props {
gitProviderId: string;
gitProviderType: "github" | "gitlab" | "bitbucket";
}
export const RemoveGitProvider = ({
gitProviderId,
gitProviderType,
}: Props) => {
const utils = api.useUtils();
const { mutateAsync } = api.gitProvider.remove.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost">
<TrashIcon className="size-4 text-muted-destructive" />
{gitProviderType === "github" && (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="size-4 fill-muted-destructive text-muted-destructive" />
</TooltipTrigger>
<TooltipContent>
We recommend deleting the GitHub app first, and then removing
the current one from here.
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the
associated github application
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
gitProviderId: gitProviderId,
})
.then(async () => {
utils.gitProvider.getAll.invalidate();
toast.success("Git Provider deleted successfully.");
})
.catch(() => {
toast.error("Error deleting Git provider.");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,190 +1,200 @@
import {
BitbucketIcon,
GitIcon,
GithubIcon,
GitlabIcon,
} from "@/components/icons/data-tools-icons";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
import { formatDate } from "date-fns";
import Link from "next/link";
import { GitBranch, Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { AddBitbucketProvider } from "./bitbucket/add-bitbucket-provider";
import { EditBitbucketProvider } from "./bitbucket/edit-bitbucket-provider";
import { AddGithubProvider } from "./github/add-github-provider";
import { EditGithubProvider } from "./github/edit-github-provider";
import { AddGitlabProvider } from "./gitlab/add-gitlab-provider";
import { EditGitlabProvider } from "./gitlab/edit-gitlab-provider";
import { RemoveGitProvider } from "./remove-git-provider";
export const ShowGitProviders = () => {
const { data } = api.gitProvider.getAll.useQuery();
const { data, isLoading, refetch } = api.gitProvider.getAll.useQuery();
const { mutateAsync, isLoading: isRemoving } =
api.gitProvider.remove.useMutation();
const url = useUrl();
const getGitlabUrl = (
clientId: string,
gitlabId: string,
gitlabUrl: string,
) => {
const redirectUri = `${url}/api/providers/gitlab/callback?gitlabId=${gitlabId}`;
const scope = "api read_user read_repository";
const authUrl = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`;
return authUrl;
};
return (
<div className="p-6 space-y-6">
<div className="space-y-2">
<h1 className="text-2xl font-bold">Git Providers</h1>
<p className="text-muted-foreground">
Connect your Git provider for authentication.
</p>
</div>
<Card className=" bg-transparent">
<CardContent className="p-4">
<div className="flex gap-4 sm:flex-row flex-col w-full">
<AddGithubProvider />
<AddGitlabProvider />
<AddBitbucketProvider />
</div>
</CardContent>
</Card>
<div className="grid gap-4 sm:grid-cols-1 md:grid-cols-1">
{data && data.length === 0 && (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<GitIcon className="size-8" />
<span className="text-base text-muted-foreground">
No Git Providers found. To add a provider, create a new one such
as GitHub, GitLab, or Bitbucket.
</span>
</div>
)}
{data?.map((gitProvider, index) => {
const isGithub = gitProvider.providerType === "github";
const isGitlab = gitProvider.providerType === "gitlab";
const isBitbucket = gitProvider.providerType === "bitbucket";
const haveGithubRequirements =
gitProvider.providerType === "github" &&
gitProvider.github?.githubPrivateKey &&
gitProvider.github?.githubAppId &&
gitProvider.github?.githubInstallationId;
const haveGitlabRequirements =
gitProvider.gitlab?.accessToken && gitProvider.gitlab?.refreshToken;
return (
<div
className="space-y-4"
key={`${gitProvider.gitProviderId}-${index}`}
>
<Card className="flex sm:flex-row max-sm:gap-2 flex-col justify-between items-center p-4 h-full bg-transparent">
<div className="flex items-center space-x-4 w-full">
{gitProvider.providerType === "github" && (
<GithubIcon className="w-6 h-6" />
)}
{gitProvider.providerType === "gitlab" && (
<GitlabIcon className="w-6 h-6" />
)}
{gitProvider.providerType === "bitbucket" && (
<BitbucketIcon className="w-6 h-6" />
)}
<div className="flex flex-col gap-1">
<p className="font-medium">
{gitProvider.providerType === "github"
? "GitHub"
: gitProvider.providerType === "gitlab"
? "GitLab"
: "Bitbucket"}
</p>
<p className="text-sm text-muted-foreground">
{gitProvider.name}
</p>
<span>
<p className="text-sm text-muted-foreground">
Created{" "}
{formatDate(
gitProvider.createdAt,
"yyyy-MM-dd hh:mm:ss a",
)}
</p>
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<GitBranch className="size-6 text-muted-foreground self-center" />
Git Providers
</CardTitle>
<CardDescription>
Connect your Git provider for authentication.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<GitBranch className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
Create your first Git Provider
</span>
<div>
<div className="flex items-center bg-sidebar p-1 w-full rounded-lg">
<div className="flex items-center gap-4 p-3.5 rounded-lg bg-background border w-full">
<AddGithubProvider />
<AddGitlabProvider />
<AddBitbucketProvider />
</div>
</div>
</div>
</div>
</div>
<div className="flex sm:gap-4 sm:flex-row flex-col">
{!haveGithubRequirements && isGithub && (
<div className="flex flex-col gap-1">
<Link
href={`${gitProvider?.github?.githubAppName}/installations/new?state=gh_setup:${gitProvider?.github.githubId}`}
className={buttonVariants({ className: "w-fit" })}
>
Install
</Link>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="flex flex-col gap-2 rounded-lg ">
<span className="text-base font-medium">
Available Providers
</span>
<div className="flex items-center bg-sidebar p-1 w-full rounded-lg">
<div className="flex items-center gap-4 p-3.5 rounded-lg bg-background border w-full">
<AddGithubProvider />
<AddGitlabProvider />
<AddBitbucketProvider />
</div>
</div>
</div>
)}
{haveGithubRequirements && isGithub && (
<div className="flex flex-col gap-1">
<Link
href={`${gitProvider?.github?.githubAppName}`}
target="_blank"
className={buttonVariants({
className: "w-fit",
variant: "secondary",
})}
>
<span className="text-sm">Manage</span>
</Link>
</div>
)}
<div className="flex flex-col gap-4 rounded-lg ">
{data?.map((gitProvider, index) => {
const isGithub = gitProvider.providerType === "github";
const isGitlab = gitProvider.providerType === "gitlab";
const isBitbucket =
gitProvider.providerType === "bitbucket";
const haveGithubRequirements =
gitProvider.providerType === "github" &&
gitProvider.github?.githubPrivateKey &&
gitProvider.github?.githubAppId &&
gitProvider.github?.githubInstallationId;
{!haveGitlabRequirements && isGitlab && (
<div className="flex flex-col gap-1">
<Link
href={getGitlabUrl(
gitProvider.gitlab?.applicationId || "",
gitProvider.gitlab?.gitlabId || "",
gitProvider.gitlab?.gitlabUrl,
)}
target="_blank"
className={buttonVariants({
className: "w-fit",
variant: "secondary",
})}
>
<span className="text-sm">Install</span>
</Link>
const haveGitlabRequirements =
gitProvider.gitlab?.accessToken &&
gitProvider.gitlab?.refreshToken;
return (
<div
key={gitProvider.gitProviderId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-col items-center justify-between">
<div className="flex gap-2 flex-row items-center">
{gitProvider.providerType === "github" && (
<GithubIcon className="size-5" />
)}
{gitProvider.providerType === "gitlab" && (
<GitlabIcon className="size-5" />
)}
{gitProvider.providerType === "bitbucket" && (
<BitbucketIcon className="size-5" />
)}
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">
{gitProvider.name}
</span>
<span className="text-xs text-muted-foreground">
{formatDate(
gitProvider.createdAt,
"yyyy-MM-dd hh:mm:ss a",
)}
</span>
</div>
</div>
</div>
<div className="flex flex-row gap-1">
{isGithub && haveGithubRequirements && (
<EditGithubProvider
githubId={gitProvider.github.githubId}
/>
)}
{isGitlab && (
<EditGitlabProvider
gitlabId={gitProvider.gitlab.gitlabId}
/>
)}
{isBitbucket && (
<EditBitbucketProvider
bitbucketId={
gitProvider.bitbucket.bitbucketId
}
/>
)}
<DialogAction
title="Delete Git Provider"
description="Are you sure you want to delete this Git Provider?"
type="destructive"
onClick={async () => {
await mutateAsync({
gitProviderId: gitProvider.gitProviderId,
})
.then(() => {
toast.success(
"Git Provider deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting Git Provider",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
</div>
);
})}
</div>
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
{/* <AddCertificate /> */}
</div>
)}
<div className="flex flex-row gap-1">
{isBitbucket && (
<EditBitbucketProvider
bitbucketId={gitProvider.bitbucket.bitbucketId}
/>
)}
{isGitlab && haveGitlabRequirements && (
<EditGitlabProvider
gitlabId={gitProvider.gitlab.gitlabId}
/>
)}
{isGithub && haveGithubRequirements && (
<EditGithubProvider
githubId={gitProvider.github.githubId}
/>
)}
<RemoveGitProvider
gitProviderId={gitProvider.gitProviderId}
gitProviderType={gitProvider.providerType}
/>
</div>
</div>
</Card>
</div>
);
})}
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};

View File

@@ -1,66 +0,0 @@
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 { Trash2 } from "lucide-react";
import React from "react";
import { toast } from "sonner";
interface Props {
notificationId: string;
}
export const DeleteNotification = ({ notificationId }: Props) => {
const { mutateAsync, isLoading } = api.notification.remove.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 group hover:bg-red-500/10"
isLoading={isLoading}
>
<Trash2 className="size-4 text-muted-foreground group-hover:text-red-500" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the
notification
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
notificationId,
})
.then(() => {
utils.notification.all.invalidate();
toast.success("Notification deleted successfully");
})
.catch(() => {
toast.error("Error deleting notification");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -28,7 +28,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, Mail } from "lucide-react";
import { AlertTriangle, Mail, PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -107,10 +107,23 @@ export const notificationsMap = {
export type NotificationSchema = z.infer<typeof notificationSchema>;
export const AddNotification = () => {
interface Props {
notificationId?: string;
}
export const HandleNotifications = ({ notificationId }: Props) => {
const utils = api.useUtils();
const [visible, setVisible] = useState(false);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: notification, refetch } = api.notification.one.useQuery(
{
notificationId: notificationId || "",
},
{
enabled: !!notificationId,
},
);
const { mutateAsync: testSlackConnection, isLoading: isLoadingSlack } =
api.notification.testSlackConnection.useMutation();
@@ -120,10 +133,18 @@ export const AddNotification = () => {
api.notification.testDiscordConnection.useMutation();
const { mutateAsync: testEmailConnection, isLoading: isLoadingEmail } =
api.notification.testEmailConnection.useMutation();
const slackMutation = api.notification.createSlack.useMutation();
const telegramMutation = api.notification.createTelegram.useMutation();
const discordMutation = api.notification.createDiscord.useMutation();
const emailMutation = api.notification.createEmail.useMutation();
const slackMutation = notificationId
? api.notification.updateSlack.useMutation()
: api.notification.createSlack.useMutation();
const telegramMutation = notificationId
? api.notification.updateTelegram.useMutation()
: api.notification.createTelegram.useMutation();
const discordMutation = notificationId
? api.notification.updateDiscord.useMutation()
: api.notification.createDiscord.useMutation();
const emailMutation = notificationId
? api.notification.updateEmail.useMutation()
: api.notification.createEmail.useMutation();
const form = useForm<NotificationSchema>({
defaultValues: {
@@ -148,8 +169,64 @@ export const AddNotification = () => {
}, [type, append]);
useEffect(() => {
form.reset();
}, [form, form.reset, form.formState.isSubmitSuccessful]);
if (notification) {
if (notification.notificationType === "slack") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
dockerCleanup: notification.dockerCleanup,
webhookUrl: notification.slack?.webhookUrl,
channel: notification.slack?.channel || "",
name: notification.name,
type: notification.notificationType,
});
} else if (notification.notificationType === "telegram") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
botToken: notification.telegram?.botToken,
chatId: notification.telegram?.chatId,
type: notification.notificationType,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
});
} else if (notification.notificationType === "discord") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
type: notification.notificationType,
webhookUrl: notification.discord?.webhookUrl,
decoration: notification.discord?.decoration || undefined,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
});
} else if (notification.notificationType === "email") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
type: notification.notificationType,
smtpServer: notification.email?.smtpServer,
smtpPort: notification.email?.smtpPort,
username: notification.email?.username,
password: notification.email?.password,
toAddresses: notification.email?.toAddresses,
fromAddress: notification.email?.fromAddress,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
});
}
} else {
form.reset();
}
}, [form, form.reset, form.formState.isSubmitSuccessful, notification]);
const activeMutation = {
slack: slackMutation,
@@ -177,6 +254,8 @@ export const AddNotification = () => {
channel: data.channel,
name: data.name,
dockerCleanup: dockerCleanup,
slackId: notification?.slackId || "",
notificationId: notificationId || "",
});
} else if (data.type === "telegram") {
promise = telegramMutation.mutateAsync({
@@ -188,6 +267,8 @@ export const AddNotification = () => {
chatId: data.chatId,
name: data.name,
dockerCleanup: dockerCleanup,
notificationId: notificationId || "",
telegramId: notification?.telegramId || "",
});
} else if (data.type === "discord") {
promise = discordMutation.mutateAsync({
@@ -199,6 +280,8 @@ export const AddNotification = () => {
decoration: data.decoration,
name: data.name,
dockerCleanup: dockerCleanup,
notificationId: notificationId || "",
discordId: notification?.discordId || "",
});
} else if (data.type === "email") {
promise = emailMutation.mutateAsync({
@@ -214,13 +297,17 @@ export const AddNotification = () => {
toAddresses: data.toAddresses,
name: data.name,
dockerCleanup: dockerCleanup,
notificationId: notificationId || "",
emailId: notification?.emailId || "",
});
}
if (promise) {
await promise
.then(async () => {
toast.success("Notification Created");
toast.success(
notificationId ? "Notification Updated" : "Notification Created",
);
form.reset({
type: "slack",
webhookUrl: "",
@@ -229,20 +316,41 @@ export const AddNotification = () => {
await utils.notification.all.invalidate();
})
.catch(() => {
toast.error("Error creating a notification");
toast.error(
notificationId
? "Error updating a notification"
: "Error creating a notification",
);
});
}
};
return (
<Dialog open={visible} onOpenChange={setVisible}>
<DialogTrigger className="" asChild>
<Button>Add Notification</Button>
{notificationId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button className="cursor-pointer space-x-3">
<PlusIcon className="h-4 w-4" />
Add Notification
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Add Notification</DialogTitle>
<DialogTitle>
{notificationId ? "Update" : "Add"} Notification
</DialogTitle>
<DialogDescription>
Create new notifications providers for multiple
{notificationId
? "Update your notification providers for multiple channels."
: "Create new notification providers for multiple channels."}
</DialogDescription>
</DialogHeader>
<Form {...form}>
@@ -264,7 +372,7 @@ export const AddNotification = () => {
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="grid w-full grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
className="grid w-full grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4"
>
{Object.entries(notificationsMap).map(([key, value]) => (
<FormItem
@@ -759,7 +867,7 @@ export const AddNotification = () => {
form="hook-form"
type="submit"
>
Create
{notificationId ? "Update" : "Create"}
</Button>
</DialogFooter>
</Form>

View File

@@ -3,6 +3,8 @@ import {
SlackIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
@@ -11,91 +13,129 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { BellRing, Mail } from "lucide-react";
import { AddNotification } from "./add-notification";
import { DeleteNotification } from "./delete-notification";
import { UpdateNotification } from "./update-notification";
import { Bell, Loader2, Mail, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { HandleNotifications } from "./handle-notifications";
export const ShowNotifications = () => {
const { data } = api.notification.all.useQuery();
const { data, isLoading, refetch } = api.notification.all.useQuery();
const { mutateAsync, isLoading: isRemoving } =
api.notification.remove.useMutation();
return (
<div className="w-full">
<Card className="h-full bg-transparent">
<CardHeader>
<CardTitle className="text-xl">Notifications</CardTitle>
<CardDescription>
Add your providers to receive notifications, like Discord, Slack,
Telegram, Email.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 pt-4">
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3">
<BellRing className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground">
To send notifications it is required to set at least 1 provider.
</span>
<AddNotification />
</div>
) : (
<div className="flex flex-col gap-4">
<div className="grid lg:grid-cols-1 xl:grid-cols-2 gap-4">
{data?.map((notification, index) => (
<div
key={notification.notificationId}
className="flex items-center justify-between rounded-xl p-4 transition-colors dark:bg-zinc-900/50 bg-gray-200/50 border border-card"
>
<div className="flex items-center gap-4">
{notification.notificationType === "slack" && (
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-500/10">
<SlackIcon className="h-6 w-6 text-indigo-400" />
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<Bell className="size-6 text-muted-foreground self-center" />
Notifications
</CardTitle>
<CardDescription>
Add your providers to receive notifications, like Discord, Slack,
Telegram, Email.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<Bell />
<span className="text-base text-muted-foreground">
To send notifications it is required to set at least 1
provider.
</span>
<HandleNotifications />
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="flex flex-col gap-4 rounded-lg ">
{data?.map((notification, index) => (
<div
key={notification.notificationId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<span className="text-sm flex flex-row items-center gap-4">
{notification.notificationType === "slack" && (
<div className="flex items-center justify-center rounded-lg">
<SlackIcon className="size-6" />
</div>
)}
{notification.notificationType === "telegram" && (
<div className="flex items-center justify-center rounded-lg ">
<TelegramIcon className="size-7 " />
</div>
)}
{notification.notificationType === "discord" && (
<div className="flex items-center justify-center rounded-lg">
<DiscordIcon className="size-7 " />
</div>
)}
{notification.notificationType === "email" && (
<div className="flex items-center justify-center rounded-lg ">
<Mail className="size-6 text-muted-foreground" />
</div>
)}
{notification.name}
</span>
<div className="flex flex-row gap-1">
<HandleNotifications
notificationId={notification.notificationId}
/>
<DialogAction
title="Delete Notification"
description="Are you sure you want to delete this notification?"
type="destructive"
onClick={async () => {
await mutateAsync({
notificationId: notification.notificationId,
})
.then(() => {
toast.success(
"Notification deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting notification",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
</div>
)}
{notification.notificationType === "telegram" && (
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-cyan-500/10">
<TelegramIcon className="h-6 w-6 text-indigo-400" />
</div>
)}
{notification.notificationType === "discord" && (
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-500/10">
<DiscordIcon className="h-6 w-6 text-indigo-400" />
</div>
)}
{notification.notificationType === "email" && (
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-zinc-500/10">
<Mail className="h-6 w-6 text-indigo-400" />
</div>
)}
<div className="flex flex-col">
<span className="text-sm font-medium dark:text-zinc-300 text-zinc-800">
{notification.name}
</span>
<span className="text-xs font-medium text-muted-foreground">
{notification.notificationType?.[0]?.toUpperCase() +
notification.notificationType?.slice(1)}{" "}
notification
</span>
</div>
))}
</div>
<div className="flex items-center gap-2">
<UpdateNotification
notificationId={notification.notificationId}
/>
<DeleteNotification
notificationId={notification.notificationId}
/>
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleNotifications />
</div>
</div>
))}
</div>
<div className="flex flex-col gap-4 justify-end w-full items-end">
<AddNotification />
</div>
</div>
)}
</CardContent>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);

View File

@@ -1,731 +0,0 @@
import {
DiscordIcon,
SlackIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { Mail, Pen } from "lucide-react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import {
type NotificationSchema,
notificationSchema,
} from "./add-notification";
interface Props {
notificationId: string;
}
export const UpdateNotification = ({ notificationId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data, refetch } = api.notification.one.useQuery(
{
notificationId,
},
{
enabled: !!notificationId,
},
);
const { mutateAsync: testSlackConnection, isLoading: isLoadingSlack } =
api.notification.testSlackConnection.useMutation();
const { mutateAsync: testTelegramConnection, isLoading: isLoadingTelegram } =
api.notification.testTelegramConnection.useMutation();
const { mutateAsync: testDiscordConnection, isLoading: isLoadingDiscord } =
api.notification.testDiscordConnection.useMutation();
const { mutateAsync: testEmailConnection, isLoading: isLoadingEmail } =
api.notification.testEmailConnection.useMutation();
const slackMutation = api.notification.updateSlack.useMutation();
const telegramMutation = api.notification.updateTelegram.useMutation();
const discordMutation = api.notification.updateDiscord.useMutation();
const emailMutation = api.notification.updateEmail.useMutation();
const { data: isCloud } = api.settings.isCloud.useQuery();
const form = useForm<NotificationSchema>({
defaultValues: {
type: "slack",
webhookUrl: "",
channel: "",
},
resolver: zodResolver(notificationSchema),
});
const type = form.watch("type");
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "toAddresses" as never,
});
useEffect(() => {
if (data) {
if (data.notificationType === "slack") {
form.reset({
appBuildError: data.appBuildError,
appDeploy: data.appDeploy,
dokployRestart: data.dokployRestart,
databaseBackup: data.databaseBackup,
dockerCleanup: data.dockerCleanup,
webhookUrl: data.slack?.webhookUrl,
channel: data.slack?.channel || "",
name: data.name,
type: data.notificationType,
});
} else if (data.notificationType === "telegram") {
form.reset({
appBuildError: data.appBuildError,
appDeploy: data.appDeploy,
dokployRestart: data.dokployRestart,
databaseBackup: data.databaseBackup,
botToken: data.telegram?.botToken,
chatId: data.telegram?.chatId,
type: data.notificationType,
name: data.name,
dockerCleanup: data.dockerCleanup,
});
} else if (data.notificationType === "discord") {
form.reset({
appBuildError: data.appBuildError,
appDeploy: data.appDeploy,
dokployRestart: data.dokployRestart,
databaseBackup: data.databaseBackup,
type: data.notificationType,
webhookUrl: data.discord?.webhookUrl,
decoration: data.discord?.decoration || undefined,
name: data.name,
dockerCleanup: data.dockerCleanup,
});
} else if (data.notificationType === "email") {
form.reset({
appBuildError: data.appBuildError,
appDeploy: data.appDeploy,
dokployRestart: data.dokployRestart,
databaseBackup: data.databaseBackup,
type: data.notificationType,
smtpServer: data.email?.smtpServer,
smtpPort: data.email?.smtpPort,
username: data.email?.username,
password: data.email?.password,
toAddresses: data.email?.toAddresses,
fromAddress: data.email?.fromAddress,
name: data.name,
dockerCleanup: data.dockerCleanup,
});
}
}
}, [form, form.reset, data]);
const onSubmit = async (formData: NotificationSchema) => {
const {
appBuildError,
appDeploy,
dokployRestart,
databaseBackup,
dockerCleanup,
} = formData;
let promise: Promise<unknown> | null = null;
if (formData?.type === "slack" && data?.slackId) {
promise = slackMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
webhookUrl: formData.webhookUrl,
channel: formData.channel,
name: formData.name,
notificationId: notificationId,
slackId: data?.slackId,
dockerCleanup: dockerCleanup,
});
} else if (formData.type === "telegram" && data?.telegramId) {
promise = telegramMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
botToken: formData.botToken,
chatId: formData.chatId,
name: formData.name,
notificationId: notificationId,
telegramId: data?.telegramId,
dockerCleanup: dockerCleanup,
});
} else if (formData.type === "discord" && data?.discordId) {
promise = discordMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
webhookUrl: formData.webhookUrl,
decoration: formData.decoration,
name: formData.name,
notificationId: notificationId,
discordId: data?.discordId,
dockerCleanup: dockerCleanup,
});
} else if (formData.type === "email" && data?.emailId) {
promise = emailMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
smtpServer: formData.smtpServer,
smtpPort: formData.smtpPort,
username: formData.username,
password: formData.password,
fromAddress: formData.fromAddress,
toAddresses: formData.toAddresses,
name: formData.name,
notificationId: notificationId,
emailId: data?.emailId,
dockerCleanup: dockerCleanup,
});
}
if (promise) {
await promise
.then(async () => {
toast.success("Notification Updated");
await utils.notification.all.invalidate();
refetch();
setIsOpen(false);
})
.catch(() => {
toast.error("Error updating a notification");
});
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 dark:hover:bg-zinc-900/80 hover:bg-gray-200/80"
>
<Pen className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Update Notification</DialogTitle>
<DialogDescription>
Update the current notification config
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="flex flex-col gap-4 ">
<div className="flex flex-row gap-2 w-full items-center">
<div className="flex flex-row gap-2 items-center w-full ">
<FormLabel className="text-lg font-semibold leading-none tracking-tight flex">
{data?.notificationType === "slack"
? "Slack"
: data?.notificationType === "telegram"
? "Telegram"
: data?.notificationType === "discord"
? "Discord"
: "Email"}
</FormLabel>
</div>
{data?.notificationType === "slack" && (
<SlackIcon className="text-muted-foreground size-6 flex-shrink-0" />
)}
{data?.notificationType === "telegram" && (
<TelegramIcon className="text-muted-foreground size-8 flex-shrink-0" />
)}
{data?.notificationType === "discord" && (
<DiscordIcon className="text-muted-foreground size-7 flex-shrink-0" />
)}
{data?.notificationType === "email" && (
<Mail
size={29}
className="text-muted-foreground size-6 flex-shrink-0"
/>
)}
</div>
<div className="flex flex-col gap-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{type === "slack" && (
<>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input
placeholder="https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="channel"
render={({ field }) => (
<FormItem>
<FormLabel>Channel</FormLabel>
<FormControl>
<Input placeholder="Channel" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{type === "telegram" && (
<>
<FormField
control={form.control}
name="botToken"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Token</FormLabel>
<FormControl>
<Input
placeholder="6660491268:AAFMGmajZOVewpMNZCgJr5H7cpXpoZPgvXw"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="chatId"
render={({ field }) => (
<FormItem>
<FormLabel>Chat ID</FormLabel>
<FormControl>
<Input placeholder="431231869" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{type === "discord" && (
<>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input
placeholder="https://discord.com/api/webhooks/123456789/ABCDEFGHIJKLMNOPQRSTUVWXYZ"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="decoration"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Decoration</FormLabel>
<FormDescription>
Decorate the notification with emojis.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</>
)}
{type === "email" && (
<>
<div className="flex md:flex-row flex-col gap-2 w-full">
<FormField
control={form.control}
name="smtpServer"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>SMTP Server</FormLabel>
<FormControl>
<Input placeholder="smtp.gmail.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="smtpPort"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>SMTP Port</FormLabel>
<FormControl>
<Input
placeholder="587"
{...field}
onChange={(e) => {
const value = e.target.value;
if (value) {
const port = Number.parseInt(value);
if (port > 0 && port < 65536) {
field.onChange(port);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex md:flex-row flex-col gap-2 w-full">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="******************"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="fromAddress"
render={({ field }) => (
<FormItem>
<FormLabel>From Address</FormLabel>
<FormControl>
<Input placeholder="from@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col gap-2 pt-2">
<FormLabel>To Addresses</FormLabel>
{fields.map((field, index) => (
<div
key={field.id}
className="flex flex-row gap-2 w-full"
>
<FormField
control={form.control}
name={`toAddresses.${index}`}
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Input
placeholder="email@example.com"
className="w-full"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="outline"
type="button"
onClick={() => {
remove(index);
}}
>
Remove
</Button>
</div>
))}
{type === "email" &&
"toAddresses" in form.formState.errors && (
<div className="text-sm font-medium text-destructive">
{form.formState?.errors?.toAddresses?.root?.message}
</div>
)}
</div>
<Button
variant="outline"
type="button"
onClick={() => {
append("");
}}
>
Add
</Button>
</>
)}
</div>
</div>
<div className="flex flex-col gap-4">
<FormLabel className="text-lg font-semibold leading-none tracking-tight">
Select the actions.
</FormLabel>
<div className="grid md:grid-cols-2 gap-4">
<FormField
control={form.control}
defaultValue={form.control._defaultValues.appDeploy}
name="appDeploy"
render={({ field }) => (
<FormItem className=" flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
<div className="space-y-0.5">
<FormLabel>App Deploy</FormLabel>
<FormDescription>
Trigger the action when a app is deployed.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
defaultValue={form.control._defaultValues.appBuildError}
name="appBuildError"
render={({ field }) => (
<FormItem className=" flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
<div className="space-y-0.5">
<FormLabel>App Builder Error</FormLabel>
<FormDescription>
Trigger the action when the build fails.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="databaseBackup"
defaultValue={form.control._defaultValues.databaseBackup}
render={({ field }) => (
<FormItem className=" flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
<div className="space-y-0.5">
<FormLabel>Database Backup</FormLabel>
<FormDescription>
Trigger the action when a database backup is created.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="dockerCleanup"
render={({ field }) => (
<FormItem className=" flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
<div className="space-y-0.5">
<FormLabel>Docker Cleanup</FormLabel>
<FormDescription>
Trigger the action when the docker cleanup is
performed.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{!isCloud && (
<FormField
control={form.control}
defaultValue={form.control._defaultValues.dokployRestart}
name="dokployRestart"
render={({ field }) => (
<FormItem className=" flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
<div className="space-y-0.5">
<FormLabel>Dokploy Restart</FormLabel>
<FormDescription>
Trigger the action when a dokploy is restarted.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
</div>
</div>
</form>
<DialogFooter className="flex flex-row gap-2 !justify-between w-full">
<Button
isLoading={
isLoadingSlack ||
isLoadingTelegram ||
isLoadingDiscord ||
isLoadingEmail
}
variant="secondary"
onClick={async () => {
try {
if (type === "slack") {
await testSlackConnection({
webhookUrl: form.getValues("webhookUrl"),
channel: form.getValues("channel"),
});
} else if (type === "telegram") {
await testTelegramConnection({
botToken: form.getValues("botToken"),
chatId: form.getValues("chatId"),
});
} else if (type === "discord") {
await testDiscordConnection({
webhookUrl: form.getValues("webhookUrl"),
decoration: form.getValues("decoration"),
});
} else if (type === "email") {
await testEmailConnection({
smtpServer: form.getValues("smtpServer"),
smtpPort: form.getValues("smtpPort"),
username: form.getValues("username"),
password: form.getValues("password"),
toAddresses: form.getValues("toAddresses"),
fromAddress: form.getValues("fromAddress"),
});
}
toast.success("Connection Success");
} catch (err) {
toast.error("Error testing the provider");
}
}}
>
Test Notification
</Button>
<Button
isLoading={form.formState.isSubmitting}
form="hook-form"
type="submit"
>
Update
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -20,54 +20,58 @@ export const GenerateToken = () => {
api.auth.generateToken.useMutation();
return (
<Card className="bg-transparent">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
<CardTitle className="text-xl">API/CLI</CardTitle>
<CardDescription>
Generate a token to access the API/CLI
</CardDescription>
</div>
<div className="flex flex-row gap-2 max-sm:flex-wrap items-end">
<span className="text-sm font-medium text-muted-foreground">
Swagger API:
</span>
<Link
href="/swagger"
target="_blank"
className="flex flex-row gap-2 items-center"
>
<span className="text-sm font-medium">View</span>
<ExternalLinkIcon className="size-4" />
</Link>
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex flex-row gap-2 max-sm:flex-wrap justify-end items-end">
<div className="grid w-full gap-8">
<div className="flex flex-col gap-2">
<Label>Token</Label>
<ToggleVisibilityInput
placeholder="Token"
value={data?.token || ""}
disabled
/>
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
<CardTitle className="text-xl">API/CLI</CardTitle>
<CardDescription>
Generate a token to access the API/CLI
</CardDescription>
</div>
</div>
<Button
type="button"
isLoading={isLoadingToken}
onClick={async () => {
await generateToken().then(() => {
refetch();
toast.success("Token generated");
});
}}
>
Generate
</Button>
<div className="flex flex-row gap-2 max-sm:flex-wrap items-end">
<span className="text-sm font-medium text-muted-foreground">
Swagger API:
</span>
<Link
href="/swagger"
target="_blank"
className="flex flex-row gap-2 items-center"
>
<span className="text-sm font-medium">View</span>
<ExternalLinkIcon className="size-4" />
</Link>
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex flex-row gap-2 max-sm:flex-wrap justify-end items-end">
<div className="grid w-full gap-8">
<div className="flex flex-col gap-2">
<Label>Token</Label>
<ToggleVisibilityInput
placeholder="Token"
value={data?.token || ""}
disabled
/>
</div>
</div>
<Button
type="button"
isLoading={isLoadingToken}
onClick={async () => {
await generateToken().then(() => {
refetch();
toast.success("Token generated");
});
}}
>
Generate
</Button>
</div>
</CardContent>
</div>
</CardContent>
</Card>
</Card>
</div>
);
};

View File

@@ -20,6 +20,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { generateSHA256Hash } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, User } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
@@ -53,9 +54,13 @@ const randomImages = [
];
export const ProfileForm = () => {
const { data, refetch } = api.auth.get.useQuery();
const { mutateAsync, isLoading, isError, error } =
api.auth.update.useMutation();
const { data, refetch, isLoading } = api.auth.get.useQuery();
const {
mutateAsync,
isLoading: isUpdating,
isError,
error,
} = api.auth.update.useMutation();
const { t } = useTranslation("settings");
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
@@ -112,124 +117,147 @@ export const ProfileForm = () => {
};
return (
<Card className="bg-transparent">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
<CardTitle className="text-xl">
{t("settings.profile.title")}
</CardTitle>
<CardDescription>{t("settings.profile.description")}</CardDescription>
</div>
{!data?.is2FAEnabled ? <Enable2FA /> : <Disable2FA />}
</CardHeader>
<CardContent className="space-y-2">
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
<div className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.profile.email")}</FormLabel>
<FormControl>
<Input
placeholder={t("settings.profile.email")}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t("settings.profile.password")}
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.profile.password")}</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t("settings.profile.password")}
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="image"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.profile.avatar")}</FormLabel>
<FormControl>
<RadioGroup
onValueChange={(e) => {
field.onChange(e);
}}
defaultValue={field.value}
value={field.value}
className="flex flex-row flex-wrap gap-2 max-xl:justify-center "
>
{availableAvatars.map((image) => (
<FormItem key={image}>
<FormLabel className="[&:has([data-state=checked])>img]:border-primary [&:has([data-state=checked])>img]:ring-ring [&:has([data-state=checked])>img]:ring-2 [&:has([data-state=checked])>img]:p-px cursor-pointer">
<FormControl>
<RadioGroupItem
value={image}
className="sr-only"
/>
</FormControl>
<img
key={image}
src={image}
alt="avatar"
className="h-12 w-12 rounded-full border hover:p-px hover:border-primary transition-transform"
/>
</FormLabel>
</FormItem>
))}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
<Button type="submit" isLoading={isLoading}>
{t("settings.common.save")}
</Button>
<CardTitle className="text-xl flex flex-row gap-2">
<User className="size-6 text-muted-foreground self-center" />
{t("settings.profile.title")}
</CardTitle>
<CardDescription>
{t("settings.profile.description")}
</CardDescription>
</div>
</form>
</Form>
</CardContent>
</Card>
{!data?.is2FAEnabled ? <Enable2FA /> : <Disable2FA />}
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[35vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid gap-4"
>
<div className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.profile.email")}</FormLabel>
<FormControl>
<Input
placeholder={t("settings.profile.email")}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t("settings.profile.password")}
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("settings.profile.password")}
</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t("settings.profile.password")}
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="image"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("settings.profile.avatar")}
</FormLabel>
<FormControl>
<RadioGroup
onValueChange={(e) => {
field.onChange(e);
}}
defaultValue={field.value}
value={field.value}
className="flex flex-row flex-wrap gap-2 max-xl:justify-center"
>
{availableAvatars.map((image) => (
<FormItem key={image}>
<FormLabel className="[&:has([data-state=checked])>img]:border-primary [&:has([data-state=checked])>img]:border-1 [&:has([data-state=checked])>img]:p-px cursor-pointer">
<FormControl>
<RadioGroupItem
value={image}
className="sr-only"
/>
</FormControl>
<img
key={image}
src={image}
alt="avatar"
className="h-12 w-12 rounded-full border hover:p-px hover:border-primary transition-transform"
/>
</FormLabel>
</FormItem>
))}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex items-center justify-end gap-2">
<Button type="submit" isLoading={isUpdating}>
{t("settings.common.save")}
</Button>
</div>
</form>
</Form>
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};

View File

@@ -69,62 +69,70 @@ export const RemoveSelfAccount = () => {
};
return (
<Card className="bg-transparent">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
<CardTitle className="text-xl">Remove Self Account</CardTitle>
<CardDescription>
If you want to remove your account, you can do it here
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-2">
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={(e) => e.preventDefault()}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
}
}}
className="grid gap-4"
>
<div className="space-y-4">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.profile.password")}</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t("settings.profile.password")}
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
<CardTitle className="text-xl">Remove Self Account</CardTitle>
<CardDescription>
If you want to remove your account, you can do it here
</CardDescription>
</div>
</form>
</Form>
<div>
<DialogAction
title="Are you sure you want to remove your account?"
description="This action cannot be undone, all your projects/servers will be deleted."
onClick={() => form.handleSubmit(onSubmit)()}
>
<Button type="button" isLoading={isLoading} variant="destructive">
Remove
</Button>
</DialogAction>
</CardHeader>
<CardContent className="space-y-2">
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={(e) => e.preventDefault()}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
}
}}
className="grid gap-4"
>
<div className="space-y-4">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.profile.password")}</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t("settings.profile.password")}
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
<div className="flex justify-end pt-2">
<DialogAction
title="Are you sure you want to remove your account?"
description="This action cannot be undone, all your projects/servers will be deleted."
onClick={() => form.handleSubmit(onSubmit)()}
>
<Button
type="button"
isLoading={isLoading}
variant="destructive"
>
Remove
</Button>
</DialogAction>
</div>
</CardContent>
</div>
</CardContent>
</Card>
</Card>
</div>
);
};

View File

@@ -15,8 +15,8 @@ import { api } from "@/utils/api";
import { useTranslation } from "next-i18next";
import { toast } from "sonner";
import { ShowModalLogs } from "../../web-server/show-modal-logs";
import { GPUSupportModal } from "../gpu-support-modal";
import { TerminalModal } from "../../web-server/terminal-modal";
import { GPUSupportModal } from "../gpu-support-modal";
export const ShowDokployActions = () => {
const { t } = useTranslation("settings");

View File

@@ -9,6 +9,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Form,
FormControl,
@@ -55,7 +56,11 @@ const Schema = z.object({
type Schema = z.infer<typeof Schema>;
export const AddServer = () => {
interface Props {
serverId?: string;
}
export const HandleServers = ({ serverId }: Props) => {
const { t } = useTranslation("settings");
const utils = api.useUtils();
@@ -63,8 +68,19 @@ export const AddServer = () => {
const { data: canCreateMoreServers, refetch } =
api.stripe.canCreateMoreServers.useQuery();
const { data, refetch: refetchServer } = api.server.one.useQuery(
{
serverId: serverId || "",
},
{
enabled: !!serverId,
},
);
const { data: sshKeys } = api.sshKey.all.useQuery();
const { mutateAsync, error, isError } = api.server.create.useMutation();
const { mutateAsync, error, isLoading, isError } = serverId
? api.server.update.useMutation()
: api.server.create.useMutation();
const form = useForm<Schema>({
defaultValues: {
description: "",
@@ -79,14 +95,14 @@ export const AddServer = () => {
useEffect(() => {
form.reset({
description: "",
name: "",
ipAddress: "",
port: 22,
username: "root",
sshKeyId: "",
description: data?.description || "",
name: data?.name || "",
ipAddress: data?.ipAddress || "",
port: data?.port || 22,
username: data?.username || "root",
sshKeyId: data?.sshKeyId || "",
});
}, [form, form.reset, form.formState.isSubmitSuccessful]);
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
useEffect(() => {
refetch();
@@ -100,30 +116,44 @@ export const AddServer = () => {
port: data.port || 22,
username: data.username || "root",
sshKeyId: data.sshKeyId || "",
serverId: serverId || "",
})
.then(async (data) => {
await utils.server.all.invalidate();
toast.success("Server Created");
refetchServer();
toast.success(serverId ? "Server Updated" : "Server Created");
setIsOpen(false);
})
.catch(() => {
toast.error("Error creating a server");
toast.error(
serverId ? "Error updating a server" : "Error creating a server",
);
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>
<PlusIcon className="h-4 w-4" />
Create Server
</Button>
{serverId ? (
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Edit Server
</DropdownMenuItem>
) : (
<Button className="cursor-pointer space-x-3">
<PlusIcon className="h-4 w-4" />
Create Server
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-3xl ">
<DialogHeader>
<DialogTitle>Add Server</DialogTitle>
<DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle>
<DialogDescription>
Add a server to deploy your applications remotely.
{serverId ? "Edit" : "Create"} a server to deploy your applications
remotely.
</DialogDescription>
</DialogHeader>
{!canCreateMoreServers && (
@@ -271,12 +301,12 @@ export const AddServer = () => {
<DialogFooter>
<Button
isLoading={form.formState.isSubmitting}
disabled={!canCreateMoreServers}
isLoading={isLoading}
disabled={!canCreateMoreServers && !serverId}
form="hook-form-add-server"
type="submit"
>
Create
{serverId ? "Update" : "Create"}
</Button>
</DialogFooter>
</Form>

View File

@@ -1,8 +1,7 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -22,16 +21,12 @@ import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/utils/api";
import copy from "copy-to-clipboard";
import {
CopyIcon,
ExternalLinkIcon,
RocketIcon,
ServerIcon,
} from "lucide-react";
import { CopyIcon, ExternalLinkIcon, ServerIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import { ShowDeployment } from "../../application/deployments/show-deployment";
import { type LogLine, parseLogs } from "../../docker/logs/utils";
import { EditScript } from "./edit-script";
import { GPUSupport } from "./gpu-support";
import { SecurityAudit } from "./security-audit";
@@ -53,15 +48,34 @@ export const SetupServer = ({ serverId }: Props) => {
);
const [activeLog, setActiveLog] = useState<string | null>(null);
const { data: deployments, refetch } = api.deployment.allByServer.useQuery(
{ serverId },
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [isDeploying, setIsDeploying] = useState(false);
api.server.setupWithLogs.useSubscription(
{
enabled: !!serverId,
serverId: serverId,
},
{
enabled: isDeploying,
onData(log) {
if (!isDrawerOpen) {
setIsDrawerOpen(true);
}
if (log === "Deployment completed successfully!") {
setIsDeploying(false);
}
const parsedLogs = parseLogs(log);
setFilteredLogs((prev) => [...prev, ...parsedLogs]);
},
onError(error) {
console.error("Deployment logs error:", error);
setIsDeploying(false);
},
},
);
const { mutateAsync, isLoading } = api.server.setup.useMutation();
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
@@ -238,87 +252,36 @@ export const SetupServer = ({ serverId }: Props) => {
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">
Deployments
Setup Server
</CardTitle>
<CardDescription>
See all the 5 Server Setup
To setup a server, please click on the button
below.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4 min-h-[25vh] items-center">
<div className="flex flex-col gap-4 items-center h-full max-w-xl mx-auto min-h-[25vh] justify-center">
<span className="text-sm text-muted-foreground text-center">
When your server is ready, you can click on the
button below, to directly run the script we use for
setup the server or directly modify the script
</span>
<div className="flex flex-row gap-2">
<EditScript serverId={server?.serverId || ""} />
<DialogAction
title={"Setup Server?"}
type="default"
description="This will setup the server and all associated data"
onClick={async () => {
await mutateAsync({
serverId: server?.serverId || "",
})
.then(async () => {
refetch();
toast.success("Server setup successfully");
})
.catch(() => {
toast.error("Error configuring server");
});
setIsDeploying(true);
}}
>
<Button isLoading={isLoading}>
Setup Server
</Button>
<Button>Setup Server</Button>
</DialogAction>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{server?.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 gap-2"
>
<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>
{deployment.description && (
<span className="break-all text-sm text-muted-foreground">
{deployment.description}
</span>
)}
</div>
<div className="flex flex-col items-end gap-2">
<div className="text-sm capitalize text-muted-foreground">
<DateTooltip date={deployment.createdAt} />
</div>
<Button
onClick={() => {
setActiveLog(deployment.logPath);
}}
>
View
</Button>
</div>
</div>
))}
</div>
)}
<ShowDeployment
open={activeLog !== null}
@@ -358,6 +321,15 @@ export const SetupServer = ({ serverId }: Props) => {
</div>
)}
</DialogContent>
<DrawerLogs
isOpen={isDrawerOpen}
onClose={() => {
setIsDrawerOpen(false);
setFilteredLogs([]);
setIsDeploying(false);
}}
filteredLogs={filteredLogs}
/>
</Dialog>
);
};

View File

@@ -6,7 +6,6 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { ContainerIcon } from "lucide-react";
import { useState } from "react";
import { ShowContainers } from "../../docker/show/show-containers";
@@ -28,17 +27,6 @@ export const ShowDockerContainersModal = ({ serverId }: Props) => {
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-7xl overflow-y-auto max-h-screen ">
<DialogHeader>
<div className="flex flex-col gap-1.5">
<DialogTitle className="flex items-center gap-2">
<ContainerIcon className="size-5" /> Docker Containers
</DialogTitle>
<p className="text-muted-foreground text-sm">
See all the containers of your remote server
</p>
</div>
</DialogHeader>
<div className="grid w-full gap-1">
<ShowContainers serverId={serverId} />
</div>

View File

@@ -2,6 +2,13 @@ import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
@@ -21,265 +28,324 @@ import {
} from "@/components/ui/table";
import { api } from "@/utils/api";
import { format } from "date-fns";
import { KeyIcon, MoreHorizontal, ServerIcon } from "lucide-react";
import { KeyIcon, Loader2, MoreHorizontal, ServerIcon } from "lucide-react";
import { useTranslation } from "next-i18next";
import Link from "next/link";
import { useRouter } from "next/router";
import { toast } from "sonner";
import { TerminalModal } from "../web-server/terminal-modal";
import { ShowServerActions } from "./actions/show-server-actions";
import { AddServer } from "./add-server";
import { HandleServers } from "./handle-servers";
import { SetupServer } from "./setup-server";
import { ShowDockerContainersModal } from "./show-docker-containers-modal";
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { UpdateServer } from "./update-server";
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
import { useTranslation } from "next-i18next";
export const ShowServers = () => {
const { t } = useTranslation("settings");
const router = useRouter();
const query = router.query;
const { data, refetch } = api.server.all.useQuery();
const { data, refetch, isLoading } = api.server.all.useQuery();
const { mutateAsync } = api.server.remove.useMutation();
const { data: sshKeys } = api.sshKey.all.useQuery();
const { data: sshKeys } = {
data: [],
};
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: canCreateMoreServers } =
api.stripe.canCreateMoreServers.useQuery();
return (
<div className="p-6 space-y-6">
<div className="w-full">
{query?.success && isCloud && <WelcomeSuscription />}
<div className="space-y-2 flex flex-row justify-between items-end">
<div className="flex flex-col gap-2">
<div>
<h1 className="text-2xl font-bold">Servers</h1>
<p className="text-muted-foreground">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<ServerIcon className="size-6 text-muted-foreground self-center" />
Servers
</CardTitle>
<CardDescription>
Add servers to deploy your applications remotely.
</p>
</div>
</CardDescription>
{isCloud && (
<span
className="text-primary cursor-pointer text-sm"
onClick={() => {
router.push("/dashboard/settings/servers?success=true");
}}
>
Reset Onboarding
</span>
)}
</div>
{sshKeys && sshKeys?.length > 0 && (
<div>
<AddServer />
</div>
)}
</div>
<div className="grid gap-4 sm:grid-cols-1 md:grid-cols-1">
{sshKeys?.length === 0 && data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<KeyIcon className="size-8" />
<span className="text-base text-muted-foreground">
No SSH Keys found. Add a SSH Key to start adding servers.{" "}
<Link
href="/dashboard/settings/ssh-keys"
className="text-primary"
{isCloud && (
<span
className="bg-gradient-to-r cursor-pointer from-blue-600 via-green-500 to-indigo-400 inline-block text-transparent bg-clip-text text-sm"
onClick={() => {
router.push("/dashboard/settings/servers?success=true");
}}
>
Add SSH Key
</Link>
</span>
</div>
) : (
data &&
data.length === 0 && (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<ServerIcon className="size-8" />
<span className="text-base text-muted-foreground">
{!canCreateMoreServers ? (
<div>
You cannot create more servers,{" "}
<Link
href="/dashboard/settings/billing"
className="text-primary"
>
Please upgrade your plan
</Link>
Reset Onboarding
</span>
)}
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<>
{sshKeys?.length === 0 && data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<KeyIcon className="size-8" />
<span className="text-base text-muted-foreground">
No SSH Keys found. Add a SSH Key to start adding servers.{" "}
<Link
href="/dashboard/settings/ssh-keys"
className="text-primary"
>
Add SSH Key
</Link>
</span>
</div>
) : (
<span>
No Servers found. Add a server to deploy your applications
remotely.
</span>
)}
</span>
</div>
)
)}
{data && data?.length > 0 && (
<div className="flex flex-col gap-6 overflow-auto">
<Table>
<TableCaption>
<div className="flex flex-col gap-4">See all servers</div>
</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Name</TableHead>
{isCloud && (
<TableHead className="text-center">Status</TableHead>
)}
<TableHead className="text-center">IP Address</TableHead>
<TableHead className="text-center">Port</TableHead>
<TableHead className="text-center">Username</TableHead>
<TableHead className="text-center">SSH Key</TableHead>
<TableHead className="text-center">Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.map((server) => {
const canDelete = server.totalSum === 0;
const isActive = server.serverStatus === "active";
return (
<TableRow key={server.serverId}>
<TableCell className="w-[100px]">{server.name}</TableCell>
{isCloud && (
<TableHead className="text-center">
<Badge
variant={
server.serverStatus === "active"
? "default"
: "destructive"
}
>
{server.serverStatus}
</Badge>
</TableHead>
)}
<TableCell className="text-center">
<Badge>{server.ipAddress}</Badge>
</TableCell>
<TableCell className="text-center">
{server.port}
</TableCell>
<TableCell className="text-center">
{server.username}
</TableCell>
<TableCell className="text-right">
<span className="text-sm text-muted-foreground">
{server.sshKeyId ? "Yes" : "No"}
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<ServerIcon className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground">
Start adding servers to deploy your applications
remotely.
</span>
</TableCell>
<TableCell className="text-right">
<span className="text-sm text-muted-foreground">
{format(new Date(server.createdAt), "PPpp")}
</span>
</TableCell>
<HandleServers />
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
{!canCreateMoreServers && (
<AlertBlock type="warning">
<div className="flex flex-row items-center gap-3 justify-center">
<span>
<div>
You cannot create more servers,{" "}
<Link
href="/dashboard/settings/billing"
className="text-primary"
>
Please upgrade your plan
</Link>
</div>
</span>
</div>
</AlertBlock>
)}
<TableCell className="text-right flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
{isActive && (
<>
{server.sshKeyId && (
<TerminalModal serverId={server.serverId}>
<span>
{t("settings.common.enterTerminal")}
<Table>
<TableCaption>
<div className="flex flex-col gap-4">
See all servers
</div>
</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Name</TableHead>
{isCloud && (
<TableHead className="text-center">
Status
</TableHead>
)}
<TableHead className="text-center">
IP Address
</TableHead>
<TableHead className="text-center">
Port
</TableHead>
<TableHead className="text-center">
Username
</TableHead>
<TableHead className="text-center">
SSH Key
</TableHead>
<TableHead className="text-center">
Created
</TableHead>
<TableHead className="text-right">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.map((server) => {
const canDelete = server.totalSum === 0;
const isActive = server.serverStatus === "active";
return (
<TableRow key={server.serverId}>
<TableCell className="w-[100px]">
{server.name}
</TableCell>
{isCloud && (
<TableHead className="text-center">
<Badge
variant={
server.serverStatus === "active"
? "default"
: "destructive"
}
>
{server.serverStatus}
</Badge>
</TableHead>
)}
<TableCell className="text-center">
<Badge>{server.ipAddress}</Badge>
</TableCell>
<TableCell className="text-center">
{server.port}
</TableCell>
<TableCell className="text-center">
{server.username}
</TableCell>
<TableCell className="text-right">
<span className="text-sm text-muted-foreground">
{server.sshKeyId ? "Yes" : "No"}
</span>
</TerminalModal>
)}
<SetupServer serverId={server.serverId} />
</TableCell>
<TableCell className="text-right">
<span className="text-sm text-muted-foreground">
{format(
new Date(server.createdAt),
"PPpp",
)}
</span>
</TableCell>
<UpdateServer serverId={server.serverId} />
{server.sshKeyId && (
<ShowServerActions
serverId={server.serverId}
/>
)}
</>
)}
<TableCell className="text-right flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
Actions
</DropdownMenuLabel>
<DialogAction
disabled={!canDelete}
title={
canDelete
? "Delete Server"
: "Server has active services"
}
description={
canDelete ? (
"This will delete the server and all associated data"
) : (
<div className="flex flex-col gap-2">
You can not delete this server because it
has active services.
<AlertBlock type="warning">
You have active services associated with
this server, please delete them first.
</AlertBlock>
</div>
)
}
onClick={async () => {
await mutateAsync({
serverId: server.serverId,
})
.then(() => {
refetch();
toast.success(
`Server ${server.name} deleted successfully`,
);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) => e.preventDefault()}
>
Delete Server
</DropdownMenuItem>
</DialogAction>
{isActive && (
<>
{server.sshKeyId && (
<TerminalModal
serverId={server.serverId}
>
<span>
{t(
"settings.common.enterTerminal",
)}
</span>
</TerminalModal>
)}
<SetupServer
serverId={server.serverId}
/>
{isActive && server.sshKeyId && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>Extra</DropdownMenuLabel>
<HandleServers
serverId={server.serverId}
/>
<ShowTraefikFileSystemModal
serverId={server.serverId}
/>
<ShowDockerContainersModal
serverId={server.serverId}
/>
<ShowSwarmOverviewModal
serverId={server.serverId}
/>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
{server.sshKeyId && (
<ShowServerActions
serverId={server.serverId}
/>
)}
</>
)}
<DialogAction
disabled={!canDelete}
title={
canDelete
? "Delete Server"
: "Server has active services"
}
description={
canDelete ? (
"This will delete the server and all associated data"
) : (
<div className="flex flex-col gap-2">
You can not delete this server
because it has active services.
<AlertBlock type="warning">
You have active services
associated with this server,
please delete them first.
</AlertBlock>
</div>
)
}
onClick={async () => {
await mutateAsync({
serverId: server.serverId,
})
.then(() => {
refetch();
toast.success(
`Server ${server.name} deleted successfully`,
);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) => e.preventDefault()}
>
Delete Server
</DropdownMenuItem>
</DialogAction>
{isActive && server.sshKeyId && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>
Extra
</DropdownMenuLabel>
<ShowTraefikFileSystemModal
serverId={server.serverId}
/>
<ShowDockerContainersModal
serverId={server.serverId}
/>
<ShowSwarmOverviewModal
serverId={server.serverId}
/>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
{data && data?.length > 0 && (
<div>
<HandleServers />
</div>
)}
</div>
</div>
)}
</>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};

View File

@@ -28,17 +28,6 @@ export const ShowSwarmOverviewModal = ({ serverId }: Props) => {
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-7xl overflow-y-auto max-h-screen ">
<DialogHeader>
<div className="flex flex-col gap-1.5">
<DialogTitle className="flex items-center gap-2">
<ContainerIcon className="size-5" />
Swarm Overview
</DialogTitle>
<p className="text-muted-foreground text-sm">
See all details of your swarm node
</p>
</div>
</DialogHeader>
<div className="grid w-full gap-1">
<SwarmMonitorCard serverId={serverId} />
</div>

View File

@@ -1,12 +1,5 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { FileTextIcon } from "lucide-react";
import { useState } from "react";
import { ShowTraefikSystem } from "../../file-system/show-traefik-system";
@@ -28,20 +21,7 @@ export const ShowTraefikFileSystemModal = ({ serverId }: Props) => {
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-7xl overflow-y-auto max-h-screen ">
<DialogHeader>
<div className="flex flex-col gap-1.5">
<DialogTitle className="flex items-center gap-2">
<FileTextIcon className="size-5" /> Traefik File System
</DialogTitle>
<p className="text-muted-foreground text-sm">
See all the files and directories of your traefik configuration
</p>
</div>
</DialogHeader>
<div id="hook-form-add-gitlab" className="grid w-full gap-1">
<ShowTraefikSystem serverId={serverId} />
</div>
<ShowTraefikSystem serverId={serverId} />
</DialogContent>
</Dialog>
);

View File

@@ -1,284 +0,0 @@
import { AlertBlock } from "@/components/shared/alert-block";
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 {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "next-i18next";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const Schema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
description: z.string().optional(),
ipAddress: z.string().min(1, {
message: "IP Address is required",
}),
port: z.number().optional(),
username: z.string().optional(),
sshKeyId: z.string().min(1, {
message: "SSH Key is required",
}),
});
type Schema = z.infer<typeof Schema>;
interface Props {
serverId: string;
}
export const UpdateServer = ({ serverId }: Props) => {
const { t } = useTranslation("settings");
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data, isLoading } = api.server.one.useQuery(
{
serverId,
},
{
enabled: !!serverId,
},
);
const { data: sshKeys } = api.sshKey.all.useQuery();
const { mutateAsync, error, isError } = api.server.update.useMutation();
const form = useForm<Schema>({
defaultValues: {
description: "",
name: "",
ipAddress: "",
port: 22,
username: "root",
sshKeyId: "",
},
resolver: zodResolver(Schema),
});
useEffect(() => {
form.reset({
description: data?.description || "",
name: data?.name || "",
ipAddress: data?.ipAddress || "",
port: data?.port || 22,
username: data?.username || "root",
sshKeyId: data?.sshKeyId || "",
});
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
const onSubmit = async (formData: Schema) => {
await mutateAsync({
name: formData.name,
description: formData.description || "",
ipAddress: formData.ipAddress || "",
port: formData.port || 22,
username: formData.username || "root",
sshKeyId: formData.sshKeyId || "",
serverId: serverId,
})
.then(async (data) => {
await utils.server.all.invalidate();
toast.success("Server Updated");
setIsOpen(false);
})
.catch(() => {
toast.error("Error updating a server");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Edit Server
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-3xl ">
<DialogHeader>
<DialogTitle>Update Server</DialogTitle>
<DialogDescription>
Update a server to deploy your applications remotely.
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-update-server"
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="Hostinger Server" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="This server is for databases..."
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sshKeyId"
render={({ field }) => (
<FormItem>
<FormLabel>Select a SSH Key</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a SSH Key" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{sshKeys?.map((sshKey) => (
<SelectItem
key={sshKey.sshKeyId}
value={sshKey.sshKeyId}
>
{sshKey.name}
</SelectItem>
))}
<SelectLabel>
Registries ({sshKeys?.length})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="ipAddress"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.terminal.ipAddress")}</FormLabel>
<FormControl>
<Input placeholder="192.168.1.100" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.terminal.port")}</FormLabel>
<FormControl>
<Input
placeholder="22"
{...field}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(0);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>{t("settings.terminal.username")}</FormLabel>
<FormControl>
<Input placeholder="root" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
<DialogFooter>
<Button
isLoading={form.formState.isSubmitting}
form="hook-form-update-server"
type="submit"
>
{t("settings.common.save")}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -23,9 +23,7 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";

View File

@@ -1,7 +1,9 @@
import { ShowDeployment } from "@/components/dashboard/application/deployments/show-deployment";
import { DateTooltip } from "@/components/shared/date-tooltip";
import {
type LogLine,
parseLogs,
} from "@/components/dashboard/docker/logs/utils";
import { DialogAction } from "@/components/shared/dialog-action";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -21,9 +23,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { RocketIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { EditScript } from "../edit-script";
export const Setup = () => {
@@ -40,16 +40,33 @@ export const Setup = () => {
},
);
const [activeLog, setActiveLog] = useState<string | null>(null);
const { data: deployments, refetch } = api.deployment.allByServer.useQuery(
{ serverId },
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [isDeploying, setIsDeploying] = useState(false);
api.server.setupWithLogs.useSubscription(
{
enabled: !!serverId,
serverId: serverId,
},
{
enabled: isDeploying,
onData(log) {
if (!isDrawerOpen) {
setIsDrawerOpen(true);
}
if (log === "Deployment completed successfully!") {
setIsDeploying(false);
}
const parsedLogs = parseLogs(log);
setFilteredLogs((prev) => [...prev, ...parsedLogs]);
},
onError(error) {
console.error("Deployment logs error:", error);
setIsDeploying(false);
},
},
);
const { mutateAsync, isLoading } = api.server.setup.useMutation();
return (
<div className="flex flex-col gap-4">
<Card className="bg-background">
@@ -74,87 +91,43 @@ export const Setup = () => {
</div>
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Deployments</CardTitle>
<CardDescription>See all the 5 Server Setup</CardDescription>
<CardTitle className="text-xl">Setup Server</CardTitle>
<CardDescription>
To setup a server, please click on the button below.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4 min-h-[25vh] items-center">
<div className="flex flex-col gap-4 items-center h-full max-w-xl mx-auto min-h-[25vh] justify-center">
<span className="text-sm text-muted-foreground text-center">
When your server is ready, you can click on the button below, to
directly run the script we use for setup the server or directly
modify the script
</span>
<div className="flex flex-row gap-2">
<EditScript serverId={server?.serverId || ""} />
<DialogAction
title={"Setup Server?"}
type="default"
description="This will setup the server and all associated data"
onClick={async () => {
await mutateAsync({
serverId: server?.serverId || "",
})
.then(async () => {
refetch();
toast.success("Server setup successfully");
})
.catch(() => {
toast.error("Error configuring server");
});
setIsDeploying(true);
}}
>
<Button isLoading={isLoading}>Setup Server</Button>
<Button>Setup Server</Button>
</DialogAction>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4 min-h-[30vh]">
{server?.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 gap-2"
>
<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>
{deployment.description && (
<span className="break-all text-sm text-muted-foreground">
{deployment.description}
</span>
)}
</div>
<div className="flex flex-col items-end gap-2">
<div className="text-sm capitalize text-muted-foreground">
<DateTooltip date={deployment.createdAt} />
</div>
<Button
onClick={() => {
setActiveLog(deployment.logPath);
}}
>
View
</Button>
</div>
</div>
))}
</div>
)}
<ShowDeployment
open={activeLog !== null}
onClose={() => setActiveLog(null)}
logPath={activeLog}
<DrawerLogs
isOpen={isDrawerOpen}
onClose={() => {
setIsDrawerOpen(false);
setFilteredLogs([]);
setIsDeploying(false);
}}
filteredLogs={filteredLogs}
/>
</CardContent>
</Card>

View File

@@ -1,61 +0,0 @@
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 React from "react";
import { toast } from "sonner";
interface Props {
sshKeyId: string;
}
export const DeleteSSHKey = ({ sshKeyId }: Props) => {
const { mutateAsync, isLoading } = api.sshKey.remove.useMutation();
const utils = api.useUtils();
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 SSH
key
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
sshKeyId,
})
.then(() => {
utils.sshKey.all.invalidate();
toast.success("SSH Key delete successfully");
})
.catch(() => {
toast.error("Error deleting SSH key");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -22,8 +22,8 @@ import { Textarea } from "@/components/ui/textarea";
import { sshKeyCreate, type sshKeyType } from "@/server/db/validations";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { type ReactNode, useState } from "react";
import { flushSync } from "react-dom";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import type { z } from "zod";
@@ -31,37 +31,71 @@ import type { z } from "zod";
type SSHKey = z.infer<typeof sshKeyCreate>;
interface Props {
children: ReactNode;
sshKeyId?: string;
}
export const AddSSHKey = ({ children }: Props) => {
export const HandleSSHKeys = ({ sshKeyId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, isError, error, isLoading } =
api.sshKey.create.useMutation();
const { data } = api.sshKey.one.useQuery(
{
sshKeyId: sshKeyId || "",
},
{
enabled: !!sshKeyId,
},
);
const { mutateAsync, isError, error, isLoading } = sshKeyId
? api.sshKey.update.useMutation()
: api.sshKey.create.useMutation();
const generateMutation = api.sshKey.generate.useMutation();
const form = useForm<SSHKey>({
resolver: zodResolver(sshKeyCreate),
defaultValues: {
name: "",
description: "",
publicKey: "",
privateKey: "",
},
});
useEffect(() => {
if (data) {
form.reset({
...data,
description: data.description || undefined,
});
} else {
form.reset();
}
}, [data, form, form.reset]);
const onSubmit = async (data: SSHKey) => {
await mutateAsync(data)
await mutateAsync({
...data,
sshKeyId: sshKeyId || "",
})
.then(async () => {
toast.success("SSH key created successfully");
toast.success(
sshKeyId
? "SSH key updated successfully"
: "SSH key created successfully",
);
await utils.sshKey.all.invalidate();
/*
Flushsync is needed for a bug witht he react-hook-form reset method
https://github.com/orgs/react-hook-form/discussions/7589#discussioncomment-10060621
*/
flushSync(() => form.reset());
form.reset();
setIsOpen(false);
})
.catch(() => {
toast.error("Error creating the SSH key");
toast.error(
sshKeyId
? "Error updating the SSH key"
: "Error creating the SSH key",
);
});
};
@@ -80,7 +114,21 @@ export const AddSSHKey = ({ children }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
{children}
{sshKeyId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button className="cursor-pointer space-x-3">
<PlusIcon className="h-4 w-4" />
Add SSH Key
</Button>
)}
{/* {children} */}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
@@ -90,34 +138,36 @@ export const AddSSHKey = ({ children }: Props) => {
In this section you can add one of your keys or generate a new
one.
</div>
<div className="flex gap-4">
<Button
variant={"secondary"}
disabled={generateMutation.isLoading}
className="max-sm:w-full"
onClick={() =>
onGenerateSSHKey({
type: "rsa",
})
}
type="button"
>
Generate RSA SSH Key
</Button>
<Button
variant={"secondary"}
disabled={generateMutation.isLoading}
className="max-sm:w-full"
onClick={() =>
onGenerateSSHKey({
type: "ed25519",
})
}
type="button"
>
Generate ED25519 SSH Key
</Button>
</div>
{!sshKeyId && (
<div className="flex gap-4">
<Button
variant={"secondary"}
disabled={generateMutation.isLoading}
className="max-sm:w-full"
onClick={() =>
onGenerateSSHKey({
type: "rsa",
})
}
type="button"
>
Generate RSA SSH Key
</Button>
<Button
variant={"secondary"}
disabled={generateMutation.isLoading}
className="max-sm:w-full"
onClick={() =>
onGenerateSSHKey({
type: "ed25519",
})
}
type="button"
>
Generate ED25519 SSH Key
</Button>
</div>
)}
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
@@ -197,7 +247,7 @@ export const AddSSHKey = ({ children }: Props) => {
/>
<DialogFooter>
<Button isLoading={isLoading} type="submit">
Create
{sshKeyId ? "Update" : "Create"}
</Button>
</DialogFooter>
</form>

View File

@@ -1,4 +1,4 @@
import { UpdateSSHKey } from "@/components/dashboard/settings/ssh-keys/update-ssh-key";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -9,87 +9,124 @@ import {
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { formatDistanceToNow } from "date-fns";
import { KeyRound, KeyRoundIcon, PenBoxIcon } from "lucide-react";
import { AddSSHKey } from "./add-ssh-key";
import { DeleteSSHKey } from "./delete-ssh-key";
import { KeyRound, Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { HandleSSHKeys } from "./handle-ssh-keys";
export const ShowDestinations = () => {
const { data } = api.sshKey.all.useQuery();
const { data, isLoading, refetch } = api.sshKey.all.useQuery();
const { mutateAsync, isLoading: isRemoving } =
api.sshKey.remove.useMutation();
return (
<div className="w-full">
<Card className="h-full bg-transparent">
<CardHeader>
<CardTitle className="text-xl">SSH Keys</CardTitle>
<CardDescription>
Use SSH to be able to clone from private repositories.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 pt-4">
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3">
<KeyRound className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground">
Add your first SSH Key
</span>
<AddSSHKey>
<Button>
<KeyRoundIcon className="size-4" /> Add SSH Key
</Button>
</AddSSHKey>
</div>
) : (
<div className="space-y-8">
<div className="flex flex-col gap-4">
<div className="flex gap-4 text-xs px-3.5">
<div className="col-span-2 basis-4/12">Key</div>
<div className="basis-3/12">Added</div>
<div>Last Used</div>
</div>
{data?.map((sshKey) => (
<div
key={sshKey.sshKeyId}
className="flex gap-4 items-center border p-3.5 rounded-lg text-sm"
>
<div className="flex flex-col basis-4/12">
<span>{sshKey.name}</span>
{sshKey.description && (
<span className="text-xs text-muted-foreground">
{sshKey.description}
</span>
)}
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<KeyRound className="size-6 text-muted-foreground self-center" />
SSH Keys
</CardTitle>
<CardDescription>
Create and manage SSH Keys, you can use them to access your
servers, git private repositories, and more.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<KeyRound className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
You don't have any SSH keys
</span>
<HandleSSHKeys />
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="flex flex-col gap-4 rounded-lg ">
{data?.map((sshKey, index) => (
<div
key={sshKey.sshKeyId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span className="text-sm font-medium">
{index + 1}. {sshKey.name}
</span>
{sshKey.description && (
<div>
<span className="text-xs text-muted-foreground">
{sshKey.description}
</span>
<div className="text-xs text-muted-foreground">
Created:{" "}
{formatDistanceToNow(
new Date(sshKey.createdAt),
{
addSuffix: true,
},
)}
</div>
</div>
)}
</div>
</div>
<div className="flex flex-row gap-1">
<HandleSSHKeys sshKeyId={sshKey.sshKeyId} />
<DialogAction
title="Delete SSH Key"
description="Are you sure you want to delete this SSH Key?"
type="destructive"
onClick={async () => {
await mutateAsync({
sshKeyId: sshKey.sshKeyId,
})
.then(() => {
toast.success(
"SSH Key deleted successfully",
);
refetch();
})
.catch(() => {
toast.error("Error deleting SSH Key");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
</div>
))}
</div>
<div className="basis-3/12">
{formatDistanceToNow(new Date(sshKey.createdAt), {
addSuffix: true,
})}
</div>
<div className="grow">
{sshKey.lastUsedAt
? formatDistanceToNow(new Date(sshKey.lastUsedAt), {
addSuffix: true,
})
: "Never"}
</div>
<div className="flex flex-row gap-1">
<UpdateSSHKey sshKeyId={sshKey.sshKeyId}>
<Button variant="ghost">
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</UpdateSSHKey>
<DeleteSSHKey sshKeyId={sshKey.sshKeyId} />
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleSSHKeys />
</div>
</div>
))}
</div>
<AddSSHKey>
<Button>
<KeyRoundIcon className="size-4" /> Add SSH Key
</Button>
</AddSSHKey>
</div>
)}
</CardContent>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);

View File

@@ -1,168 +0,0 @@
import { DeleteSSHKey } from "@/components/dashboard/settings/ssh-keys/delete-ssh-key";
import { AlertBlock } from "@/components/shared/alert-block";
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 { sshKeyUpdate } from "@/server/db/validations";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard";
import { CopyIcon } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import type { z } from "zod";
type SSHKey = z.infer<typeof sshKeyUpdate>;
interface Props {
children: ReactNode;
sshKeyId?: string;
}
export const UpdateSSHKey = ({ children, sshKeyId = "" }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data } = api.sshKey.one.useQuery({
sshKeyId,
});
const { mutateAsync, isError, error, isLoading } =
api.sshKey.update.useMutation();
const form = useForm<SSHKey>({
resolver: zodResolver(sshKeyUpdate),
});
useEffect(() => {
if (data) {
form.reset({
...data,
/* Convert null to undefined */
description: data.description || undefined,
});
}
}, [data]);
const onSubmit = async (data: SSHKey) => {
await mutateAsync({
sshKeyId,
...data,
})
.then(async () => {
toast.success("SSH Key Updated");
await utils.sshKey.all.invalidate();
setIsOpen(false);
})
.catch(() => {
toast.error("Error updating the SSH key");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
{children}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>SSH Key</DialogTitle>
<DialogDescription>
In this section you can edit an SSH key
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
className="grid w-full gap-4 "
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="name"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder={"Personal projects"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
placeholder={"Used on my personal Hetzner VPS"}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormItem>
<FormLabel>Public Key</FormLabel>
<FormControl>
<div className="relative">
<Textarea
rows={7}
readOnly
disabled
value={data?.publicKey}
/>
<button
type="button"
className="absolute right-2 top-2"
onClick={() => {
copy(data?.publicKey || "Generate a SSH Key");
toast.success("SSH Copied to clipboard");
}}
>
<CopyIcon className="size-4" />
</button>
</div>
</FormControl>
<FormMessage />
</FormItem>
<DialogFooter>
<Button isLoading={isLoading} type="submit">
Update
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -22,7 +22,7 @@ import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -37,6 +37,7 @@ const addUser = z.object({
type AddUser = z.infer<typeof addUser>;
export const AddUser = () => {
const [open, setOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, isError, error, isLoading } =
@@ -59,13 +60,14 @@ export const AddUser = () => {
.then(async () => {
toast.success("Invitation created");
await utils.user.all.invalidate();
setOpen(false);
})
.catch(() => {
toast.error("Error creating the invitation");
});
};
return (
<Dialog>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="" asChild>
<Button>
<PlusIcon className="h-4 w-4" /> Add User

View File

@@ -1,64 +0,0 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { api } from "@/utils/api";
import { TrashIcon } from "lucide-react";
import React from "react";
import { toast } from "sonner";
interface Props {
authId: string;
}
export const DeleteUser = ({ authId }: Props) => {
const { mutateAsync, isLoading } = api.admin.removeUser.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) => e.preventDefault()}
>
Delete User
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the user.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
authId,
})
.then(async () => {
utils.user.all.invalidate();
toast.success("User deleted successfully");
})
.catch(() => {
toast.error("Error deleting User");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -31,123 +31,174 @@ import { useEffect, useState } from "react";
import { toast } from "sonner";
import { AddUserPermissions } from "./add-permissions";
import { AddUser } from "./add-user";
import { DeleteUser } from "./delete-user";
import { DialogAction } from "@/components/shared/dialog-action";
import { Loader2 } from "lucide-react";
export const ShowUsers = () => {
const { data } = api.user.all.useQuery();
const [url, setUrl] = useState("");
useEffect(() => {
setUrl(document.location.origin);
}, []);
const { data, isLoading, refetch } = api.user.all.useQuery();
const { mutateAsync, isLoading: isRemoving } =
api.admin.removeUser.useMutation();
return (
<div className=" col-span-2">
<Card className="bg-transparent ">
<CardHeader className="flex flex-row gap-2 justify-between w-full flex-wrap">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl">Users</CardTitle>
<CardDescription>Add, manage and delete users.</CardDescription>
</div>
{data && data.length > 0 && (
<div className="flex flex-col gap-3 items-end">
<AddUser />
</div>
)}
</CardHeader>
<CardContent className="space-y-2">
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 h-full">
<Users className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground">
To create a user, you need to add:
</span>
<AddUser />
</div>
) : (
<div className="flex flex-col gap-6">
<Table>
<TableCaption>See all users</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Email</TableHead>
<TableHead className="text-center">Status</TableHead>
<TableHead className="text-center">2FA</TableHead>
<TableHead className="text-center">Expiration</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.map((user) => {
return (
<TableRow key={user.userId}>
<TableCell className="w-[100px]">
{user.auth.email}
</TableCell>
<TableCell className="text-center">
<Badge
variant={
user.isRegistered ? "default" : "secondary"
}
>
{user.isRegistered
? "Registered"
: "Not Registered"}
</Badge>
</TableCell>
<TableCell className="text-center">
{user.auth.is2FAEnabled
? "2FA Enabled"
: "2FA Not Enabled"}
</TableCell>
<TableCell className="text-right">
<span className="text-sm text-muted-foreground">
{format(new Date(user.expirationDate), "PPpp")}
</span>
</TableCell>
<TableCell className="text-right flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
{!user.isRegistered && (
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => {
copy(
`${origin}/invitation?token=${user.token}`,
);
toast.success(
"Invitation Copied to clipboard",
);
}}
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<Users className="size-6 text-muted-foreground self-center" />
Users
</CardTitle>
<CardDescription>
Add your users to your Dokploy account.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<Users className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground">
Invite users to your Dokploy account
</span>
<AddUser />
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<Table>
<TableCaption>See all users</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Email</TableHead>
<TableHead className="text-center">Status</TableHead>
<TableHead className="text-center">2FA</TableHead>
<TableHead className="text-center">
Expiration
</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.map((user) => {
return (
<TableRow key={user.userId}>
<TableCell className="w-[100px]">
{user.auth.email}
</TableCell>
<TableCell className="text-center">
<Badge
variant={
user.isRegistered ? "default" : "secondary"
}
>
Copy Invitation
</DropdownMenuItem>
)}
{user.isRegistered
? "Registered"
: "Not Registered"}
</Badge>
</TableCell>
<TableCell className="text-center">
{user.auth.is2FAEnabled
? "2FA Enabled"
: "2FA Not Enabled"}
</TableCell>
<TableCell className="text-right">
<span className="text-sm text-muted-foreground">
{format(
new Date(user.expirationDate),
"PPpp",
)}
</span>
</TableCell>
{user.isRegistered && (
<AddUserPermissions userId={user.userId} />
)}
<TableCell className="text-right flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
Actions
</DropdownMenuLabel>
{!user.isRegistered && (
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => {
copy(
`${origin}/invitation?token=${user.token}`,
);
toast.success(
"Invitation Copied to clipboard",
);
}}
>
Copy Invitation
</DropdownMenuItem>
)}
<DeleteUser authId={user.authId} />
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
{user.isRegistered && (
<AddUserPermissions
userId={user.userId}
/>
)}
<DialogAction
title="Delete User"
description="Are you sure you want to delete this user?"
type="destructive"
onClick={async () => {
await mutateAsync({
authId: user.authId,
})
.then(() => {
toast.success(
"User deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting destination",
);
});
}}
>
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) => e.preventDefault()}
>
Delete User
</DropdownMenuItem>
</DialogAction>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<AddUser />
</div>
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);

View File

@@ -24,6 +24,7 @@ import {
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { GlobeIcon, ServerIcon, User } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -87,114 +88,120 @@ export const WebDomain = () => {
toast.error("Error assigning the domain");
});
};
return (
<div className="w-full">
<Card className="bg-transparent">
<CardHeader>
<CardTitle className="text-xl">
{t("settings.server.domain.title")}
</CardTitle>
<CardDescription>
{t("settings.server.domain.description")}
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 md:grid-cols-2"
>
<FormField
control={form.control}
name="domain"
render={({ field }) => {
return (
<FormItem>
<FormLabel>
{t("settings.server.domain.form.domain")}
</FormLabel>
<FormControl>
<Input
className="w-full"
placeholder={"dokploy.com"}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="letsEncryptEmail"
render={({ field }) => {
return (
<FormItem>
<FormLabel>
{t("settings.server.domain.form.letsEncryptEmail")}
</FormLabel>
<FormControl>
<Input
className="w-full"
placeholder={"Dp4kz@example.com"}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="certificateType"
render={({ field }) => {
return (
<FormItem className="md:col-span-2">
<FormLabel>
{t("settings.server.domain.form.certificate.label")}
</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl flex flex-row gap-2">
<GlobeIcon className="size-6 text-muted-foreground self-center" />
{t("settings.server.domain.title")}
</CardTitle>
<CardDescription>
{t("settings.server.domain.description")}
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-2 py-6 border-t">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 md:grid-cols-2"
>
<FormField
control={form.control}
name="domain"
render={({ field }) => {
return (
<FormItem>
<FormLabel>
{t("settings.server.domain.form.domain")}
</FormLabel>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"settings.server.domain.form.certificate.placeholder",
)}
/>
</SelectTrigger>
<Input
className="w-full"
placeholder={"dokploy.com"}
{...field}
/>
</FormControl>
<SelectContent>
<SelectItem value={"none"}>
{t(
"settings.server.domain.form.certificateOptions.none",
)}
</SelectItem>
<SelectItem value={"letsencrypt"}>
{t(
"settings.server.domain.form.certificateOptions.letsencrypt",
)}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
);
}}
/>
<div>
<Button isLoading={isLoading} type="submit">
{t("settings.common.save")}
</Button>
</div>
</form>
</Form>
</CardContent>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="letsEncryptEmail"
render={({ field }) => {
return (
<FormItem>
<FormLabel>
{t("settings.server.domain.form.letsEncryptEmail")}
</FormLabel>
<FormControl>
<Input
className="w-full"
placeholder={"Dp4kz@example.com"}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="certificateType"
render={({ field }) => {
return (
<FormItem className="md:col-span-2">
<FormLabel>
{t("settings.server.domain.form.certificate.label")}
</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"settings.server.domain.form.certificate.placeholder",
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={"none"}>
{t(
"settings.server.domain.form.certificateOptions.none",
)}
</SelectItem>
<SelectItem value={"letsencrypt"}>
{t(
"settings.server.domain.form.certificateOptions.letsencrypt",
)}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
);
}}
/>
<div className="flex w-full justify-end col-span-2">
<Button isLoading={isLoading} type="submit">
{t("settings.common.save")}
</Button>
</div>
</form>
</Form>
</CardContent>
</div>
</Card>
</div>
);

View File

@@ -7,6 +7,7 @@ import {
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { ServerIcon } from "lucide-react";
import { useTranslation } from "next-i18next";
import React from "react";
import { ShowDokployActions } from "./servers/actions/show-dokploy-actions";
@@ -25,35 +26,49 @@ export const WebServer = ({ className }: Props) => {
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
return (
<Card className={cn("rounded-lg w-full bg-transparent p-0", className)}>
<CardHeader>
<CardTitle className="text-xl">
{t("settings.server.webServer.title")}
</CardTitle>
<CardDescription>
{t("settings.server.webServer.description")}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 ">
<div className="grid md:grid-cols-2 gap-4">
<ShowDokployActions />
<ShowTraefikActions />
<ShowStorageActions />
<div className="w-full">
{/* <Card className={cn("rounded-lg w-full bg-transparent p-0", className)}></Card> */}
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<ServerIcon className="size-6 text-muted-foreground self-center" />
{t("settings.server.webServer.title")}
</CardTitle>
<CardDescription>
{t("settings.server.webServer.description")}
</CardDescription>
</CardHeader>
{/* <CardHeader>
<CardTitle className="text-xl">
{t("settings.server.webServer.title")}
</CardTitle>
<CardDescription>
{t("settings.server.webServer.description")}
</CardDescription>
</CardHeader> */}
<CardContent className="space-y-6 py-6 border-t">
<div className="grid md:grid-cols-2 gap-4">
<ShowDokployActions />
<ShowTraefikActions />
<ShowStorageActions />
<UpdateServer />
<UpdateServer />
</div>
<div className="flex items-center flex-wrap justify-between gap-4">
<span className="text-sm text-muted-foreground">
Server IP: {data?.serverIp}
</span>
<span className="text-sm text-muted-foreground">
Version: {dokployVersion}
</span>
<ToggleDockerCleanup />
</div>
</CardContent>
</div>
<div className="flex items-center flex-wrap justify-between gap-4">
<span className="text-sm text-muted-foreground">
Server IP: {data?.serverIp}
</span>
<span className="text-sm text-muted-foreground">
Version: {dokployVersion}
</span>
<ToggleDockerCleanup />
</div>
</CardContent>
</Card>
</Card>
</div>
);
};

View File

@@ -1,26 +1,26 @@
import { Button, buttonVariants } from "@/components/ui/button";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Input } from "@/components/ui/input";
import { Button, buttonVariants } from "@/components/ui/button";
import {
FormControl,
FormLabel,
FormField,
FormMessage,
FormItem,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { Settings } from "lucide-react";
import { useTranslation } from "next-i18next";
import React from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useTranslation } from "next-i18next";
const Schema = z.object({
port: z.number().min(1, "Port must be higher than 0"),

View File

@@ -1,55 +0,0 @@
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";
export const RemoveSSHPrivateKey = () => {
const utils = api.useUtils();
const { mutateAsync, isLoading } =
api.settings.cleanSSHPrivateKey.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" isLoading={isLoading}>
Remove Current SSH Private Key
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the ssh
private key.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync()
.then(() => {
toast.success("SSH private key deleted successfully");
utils.auth.get.invalidate();
})
.catch(() => {
toast.error("Error deleting the SSH private key");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -7,6 +7,7 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import type { IUpdateData } from "@dokploy/server/index";
import {
Bug,
Download,
@@ -22,7 +23,6 @@ import { useState } from "react";
import { toast } from "sonner";
import { ToggleAutoCheckUpdates } from "./toggle-auto-check-updates";
import { UpdateWebServer } from "./update-webserver";
import type { IUpdateData } from "@dokploy/server/index";
interface Props {
updateData?: IUpdateData;