Merge branch 'canary' into feat/migration-templates

This commit is contained in:
Mauricio Siu
2025-03-09 12:36:14 -06:00
452 changed files with 78354 additions and 6009 deletions

View File

@@ -1,6 +1,6 @@
name: Bug Report name: Bug Report
description: Create a bug report description: Create a bug report
labels: ["bug"] labels: ["needs-triage🔍"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
@@ -62,6 +62,7 @@ body:
- "Docker" - "Docker"
- "Remote server" - "Remote server"
- "Local Development" - "Local Development"
- "Cloud Version"
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@@ -2,7 +2,7 @@ name: Dokploy Docker Build
on: on:
push: push:
branches: [main, canary, "feat/monitoring"] branches: [main, canary, "feat/better-auth-2"]
env: env:
IMAGE_NAME: dokploy/dokploy IMAGE_NAME: dokploy/dokploy

View File

@@ -138,11 +138,18 @@ curl -sSL https://nixpacks.com/install.sh -o install.sh \
&& ./install.sh && ./install.sh
``` ```
```bash
# Install Railpack
curl -sSL https://railpack.com/install.sh | sh
```
```bash ```bash
# Install Buildpacks # Install Buildpacks
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.32.1/pack-v0.32.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.32.1/pack-v0.32.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
``` ```
## Pull Request ## Pull Request
- The `main` branch is the source of truth and should always reflect the latest stable release. - The `main` branch is the source of truth and should always reflect the latest stable release.

View File

@@ -55,6 +55,10 @@ RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
&& ./install.sh \ && ./install.sh \
&& pnpm install -g tsx && pnpm install -g tsx
# Install Railpack
ARG RAILPACK_VERSION=0.0.37
RUN curl -sSL https://railpack.com/install.sh | bash
# Install buildpacks # Install buildpacks
COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack

View File

@@ -28,7 +28,7 @@ app.use(async (c, next) => {
app.post("/deploy", zValidator("json", deployJobSchema), (c) => { app.post("/deploy", zValidator("json", deployJobSchema), (c) => {
const data = c.req.valid("json"); const data = c.req.valid("json");
const res = queue.add(data, { groupName: data.serverId }); queue.add(data, { groupName: data.serverId });
return c.json( return c.json(
{ {
message: "Deployment Added", message: "Deployment Added",

View File

@@ -64,7 +64,7 @@ export const deploy = async (job: DeployJob) => {
} }
} }
} }
} catch (error) { } catch (_) {
if (job.applicationType === "application") { if (job.applicationType === "application") {
await updateApplicationStatus(job.applicationId, "error"); await updateApplicationStatus(job.applicationId, "error");
} else if (job.applicationType === "compose") { } else if (job.applicationType === "compose") {

View File

@@ -1,5 +1,5 @@
import { generateRandomHash } from "@dokploy/server"; import { generateRandomHash } from "@dokploy/server";
import { addSuffixToAllConfigs, addSuffixToConfigsRoot } from "@dokploy/server"; import { addSuffixToAllConfigs } from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server"; import type { ComposeSpecification } from "@dokploy/server";
import { load } from "js-yaml"; import { load } from "js-yaml";
import { expect, test } from "vitest"; import { expect, test } from "vitest";

View File

@@ -9,6 +9,7 @@ describe("createDomainLabels", () => {
port: 8080, port: 8080,
https: false, https: false,
uniqueConfigKey: 1, uniqueConfigKey: 1,
customCertResolver: null,
certificateType: "none", certificateType: "none",
applicationId: "", applicationId: "",
composeId: "", composeId: "",

View File

@@ -293,29 +293,6 @@ networks:
dokploy-network: dokploy-network:
`; `;
const expectedComposeFile7 = `
version: "3.8"
services:
web:
image: nginx:latest
networks:
- dokploy-network
networks:
dokploy-network:
driver: bridge
driver_opts:
com.docker.network.driver.mtu: 1200
backend:
driver: bridge
attachable: true
external_network:
external: true
name: dokploy-network
`;
test("It shoudn't add suffix to dokploy-network", () => { test("It shoudn't add suffix to dokploy-network", () => {
const composeData = load(composeFile7) as ComposeSpecification; const composeData = load(composeFile7) as ComposeSpecification;

View File

@@ -1,7 +1,7 @@
import { generateRandomHash } from "@dokploy/server"; import { generateRandomHash } from "@dokploy/server";
import { addSuffixToSecretsRoot } from "@dokploy/server"; import { addSuffixToSecretsRoot } from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server"; import type { ComposeSpecification } from "@dokploy/server";
import { dump, load } from "js-yaml"; import { load } from "js-yaml";
import { expect, test } from "vitest"; import { expect, test } from "vitest";
test("Generate random hash with 8 characters", () => { test("Generate random hash with 8 characters", () => {

View File

@@ -1,8 +1,4 @@
import { generateRandomHash } from "@dokploy/server"; import { addSuffixToAllVolumes } from "@dokploy/server";
import {
addSuffixToAllVolumes,
addSuffixToVolumesInServices,
} from "@dokploy/server";
import type { ComposeSpecification } from "@dokploy/server"; import type { ComposeSpecification } from "@dokploy/server";
import { load } from "js-yaml"; import { load } from "js-yaml";
import { expect, test } from "vitest"; import { expect, test } from "vitest";

View File

@@ -27,6 +27,7 @@ if (typeof window === "undefined") {
const baseApp: ApplicationNested = { const baseApp: ApplicationNested = {
applicationId: "", applicationId: "",
herokuVersion: "", herokuVersion: "",
watchPaths: [],
applicationStatus: "done", applicationStatus: "done",
appName: "", appName: "",
autoDeploy: true, autoDeploy: true,
@@ -37,6 +38,7 @@ const baseApp: ApplicationNested = {
isPreviewDeploymentsActive: false, isPreviewDeploymentsActive: false,
previewBuildArgs: null, previewBuildArgs: null,
previewCertificateType: "none", previewCertificateType: "none",
previewCustomCertResolver: null,
previewEnv: null, previewEnv: null,
previewHttps: false, previewHttps: false,
previewPath: "/", previewPath: "/",
@@ -45,7 +47,7 @@ const baseApp: ApplicationNested = {
previewWildcard: "", previewWildcard: "",
project: { project: {
env: "", env: "",
adminId: "", organizationId: "",
name: "", name: "",
description: "", description: "",
createdAt: "", createdAt: "",

View File

@@ -5,7 +5,7 @@ vi.mock("node:fs", () => ({
default: fs, default: fs,
})); }));
import type { Admin, FileConfig } from "@dokploy/server"; import type { FileConfig, User } from "@dokploy/server";
import { import {
createDefaultServerTraefikConfig, createDefaultServerTraefikConfig,
loadOrCreateConfig, loadOrCreateConfig,
@@ -13,7 +13,7 @@ import {
} from "@dokploy/server"; } from "@dokploy/server";
import { beforeEach, expect, test, vi } from "vitest"; import { beforeEach, expect, test, vi } from "vitest";
const baseAdmin: Admin = { const baseAdmin: User = {
enablePaidFeatures: false, enablePaidFeatures: false,
metricsConfig: { metricsConfig: {
containers: { containers: {
@@ -40,19 +40,30 @@ const baseAdmin: Admin = {
cleanupCacheApplications: false, cleanupCacheApplications: false,
cleanupCacheOnCompose: false, cleanupCacheOnCompose: false,
cleanupCacheOnPreviews: false, cleanupCacheOnPreviews: false,
createdAt: "", createdAt: new Date(),
authId: "",
adminId: "string",
serverIp: null, serverIp: null,
certificateType: "none", certificateType: "none",
host: null, host: null,
letsEncryptEmail: null, letsEncryptEmail: null,
sshPrivateKey: null, sshPrivateKey: null,
enableDockerCleanup: false, enableDockerCleanup: false,
enableLogRotation: false, logCleanupCron: null,
serversQuantity: 0, serversQuantity: 0,
stripeCustomerId: "", stripeCustomerId: "",
stripeSubscriptionId: "", stripeSubscriptionId: "",
banExpires: new Date(),
banned: true,
banReason: "",
email: "",
expirationDate: "",
id: "",
isRegistered: false,
name: "",
createdAt2: new Date().toISOString(),
emailVerified: false,
image: "",
updatedAt: new Date(),
twoFactorEnabled: false,
}; };
beforeEach(() => { beforeEach(() => {
@@ -103,8 +114,6 @@ test("Should not touch config without host", () => {
}); });
test("Should remove websecure if https rollback to http", () => { test("Should remove websecure if https rollback to http", () => {
const originalConfig: FileConfig = loadOrCreateConfig("dokploy");
updateServerTraefik( updateServerTraefik(
{ ...baseAdmin, certificateType: "letsencrypt" }, { ...baseAdmin, certificateType: "letsencrypt" },
"example.com", "example.com",

View File

@@ -14,6 +14,7 @@ const baseApp: ApplicationNested = {
branch: null, branch: null,
dockerBuildStage: "", dockerBuildStage: "",
registryUrl: "", registryUrl: "",
watchPaths: [],
buildArgs: null, buildArgs: null,
isPreviewDeploymentsActive: false, isPreviewDeploymentsActive: false,
previewBuildArgs: null, previewBuildArgs: null,
@@ -23,10 +24,11 @@ const baseApp: ApplicationNested = {
previewPath: "/", previewPath: "/",
previewPort: 3000, previewPort: 3000,
previewLimit: 0, previewLimit: 0,
previewCustomCertResolver: null,
previewWildcard: "", previewWildcard: "",
project: { project: {
env: "", env: "",
adminId: "", organizationId: "",
name: "", name: "",
description: "", description: "",
createdAt: "", createdAt: "",
@@ -103,6 +105,7 @@ const baseDomain: Domain = {
port: null, port: null,
serviceName: "", serviceName: "",
composeId: "", composeId: "",
customCertResolver: null,
domainType: "application", domainType: "application",
uniqueConfigKey: 1, uniqueConfigKey: 1,
previewDeploymentId: "", previewDeploymentId: "",

View File

@@ -1,132 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { CardTitle } from "@/components/ui/card";
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import { AlertTriangle } from "lucide-react";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const Login2FASchema = z.object({
pin: z.string().min(6, {
message: "Pin is required",
}),
});
type Login2FA = z.infer<typeof Login2FASchema>;
interface Props {
authId: string;
}
export const Login2FA = ({ authId }: Props) => {
const { push } = useRouter();
const { mutateAsync, isLoading, isError, error } =
api.auth.verifyLogin2FA.useMutation();
const form = useForm<Login2FA>({
defaultValues: {
pin: "",
},
resolver: zodResolver(Login2FASchema),
});
useEffect(() => {
form.reset({
pin: "",
});
}, [form, form.reset, form.formState.isSubmitSuccessful]);
const onSubmit = async (data: Login2FA) => {
await mutateAsync({
pin: data.pin,
id: authId,
})
.then(() => {
toast.success("Signin successfully", {
duration: 2000,
});
push("/dashboard/projects");
})
.catch(() => {
toast.error("Signin failed", {
duration: 2000,
});
});
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
{isError && (
<div className="flex flex-row gap-4 rounded-lg items-center 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>
)}
<CardTitle className="text-xl font-bold">2FA Login</CardTitle>
<FormField
control={form.control}
name="pin"
render={({ field }) => (
<FormItem className="flex flex-col max-sm:items-center">
<FormLabel>Pin</FormLabel>
<FormControl>
<div className="flex">
<InputOTP
maxLength={6}
{...field}
pattern={REGEXP_ONLY_DIGITS}
>
<InputOTPGroup>
<InputOTPSlot index={0} className="border-border" />
<InputOTPSlot index={1} className="border-border" />
<InputOTPSlot index={2} className="border-border" />
<InputOTPSlot index={3} className="border-border" />
<InputOTPSlot index={4} className="border-border" />
<InputOTPSlot index={5} className="border-border" />
</InputOTPGroup>
</InputOTP>
</div>
</FormControl>
<FormDescription>
Please enter the 6 digits code provided by your authenticator
app.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button isLoading={isLoading} type="submit">
Submit 2FA
</Button>
</form>
</Form>
);
};

View File

@@ -130,7 +130,7 @@ const createStringToJSONSchema = (schema: z.ZodTypeAny) => {
} }
try { try {
return JSON.parse(str); return JSON.parse(str);
} catch (e) { } catch (_e) {
ctx.addIssue({ code: "custom", message: "Invalid JSON format" }); ctx.addIssue({ code: "custom", message: "Invalid JSON format" });
return z.NEVER; return z.NEVER;
} }

View File

@@ -29,7 +29,6 @@ import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Server } from "lucide-react"; import { Server } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import React from "react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";

View File

@@ -17,7 +17,6 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import React from "react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";

View File

@@ -10,7 +10,6 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Rss, Trash2 } from "lucide-react"; import { Rss, Trash2 } from "lucide-react";
import React from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { HandlePorts } from "./handle-ports"; import { HandlePorts } from "./handle-ports";
interface Props { interface Props {

View File

@@ -9,7 +9,6 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Split, Trash2 } from "lucide-react"; import { Split, Trash2 } from "lucide-react";
import React from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { HandleRedirect } from "./handle-redirect"; import { HandleRedirect } from "./handle-redirect";

View File

@@ -9,7 +9,6 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { LockKeyhole, Trash2 } from "lucide-react"; import { LockKeyhole, Trash2 } from "lucide-react";
import React from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { HandleSecurity } from "./handle-security"; import { HandleSecurity } from "./handle-security";

View File

@@ -25,7 +25,7 @@ import {
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { InfoIcon } from "lucide-react"; import { InfoIcon } from "lucide-react";
import React, { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";

View File

@@ -8,7 +8,6 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { File, Loader2 } from "lucide-react"; import { File, Loader2 } from "lucide-react";
import React from "react";
import { UpdateTraefikConfig } from "./update-traefik-config"; import { UpdateTraefikConfig } from "./update-traefik-config";
interface Props { interface Props {
applicationId: string; applicationId: string;

View File

@@ -10,7 +10,6 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Package, Trash2 } from "lucide-react"; import { Package, Trash2 } from "lucide-react";
import React from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { ServiceType } from "../show-resources"; import type { ServiceType } from "../show-resources";
import { AddVolumes } from "./add-volumes"; import { AddVolumes } from "./add-volumes";

View File

@@ -21,7 +21,7 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, Pencil } from "lucide-react"; import { PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -77,7 +77,7 @@ export const UpdateVolume = ({
serviceType, serviceType,
}: Props) => { }: Props) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils(); const _utils = api.useUtils();
const { data } = api.mounts.one.useQuery( const { data } = api.mounts.one.useQuery(
{ {
mountId, mountId,

View File

@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
@@ -25,6 +26,7 @@ enum BuildType {
paketo_buildpacks = "paketo_buildpacks", paketo_buildpacks = "paketo_buildpacks",
nixpacks = "nixpacks", nixpacks = "nixpacks",
static = "static", static = "static",
railpack = "railpack",
} }
const mySchema = z.discriminatedUnion("buildType", [ const mySchema = z.discriminatedUnion("buildType", [
@@ -53,6 +55,9 @@ const mySchema = z.discriminatedUnion("buildType", [
z.object({ z.object({
buildType: z.literal("static"), buildType: z.literal("static"),
}), }),
z.object({
buildType: z.literal("railpack"),
}),
]); ]);
type AddTemplate = z.infer<typeof mySchema>; type AddTemplate = z.infer<typeof mySchema>;
@@ -173,6 +178,15 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
Dockerfile Dockerfile
</FormLabel> </FormLabel>
</FormItem> </FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="railpack" />
</FormControl>
<FormLabel className="font-normal">
Railpack{" "}
<Badge className="ml-1 text-xs px-1">New</Badge>
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0"> <FormItem className="flex items-center space-x-3 space-y-0">
<FormControl> <FormControl>
<RadioGroupItem value="nixpacks" /> <RadioGroupItem value="nixpacks" />

View File

@@ -11,7 +11,6 @@ import {
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { RefreshCcw } from "lucide-react"; import { RefreshCcw } from "lucide-react";
import React from "react";
import { toast } from "sonner"; import { toast } from "sonner";
interface Props { interface Props {

View File

@@ -73,15 +73,14 @@ export const ShowDeployments = ({ applicationId }: Props) => {
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{deployments?.map((deployment) => ( {deployments?.map((deployment, index) => (
<div <div
key={deployment.deploymentId} key={deployment.deploymentId}
className="flex items-center justify-between rounded-lg border p-4 gap-2" className="flex items-center justify-between rounded-lg border p-4 gap-2"
> >
<div className="flex flex-col"> <div className="flex flex-col">
<span className="flex items-center gap-4 font-medium capitalize text-foreground"> <span className="flex items-center gap-4 font-medium capitalize text-foreground">
{deployment.status} {index + 1}. {deployment.status}
<StatusTooltip <StatusTooltip
status={deployment?.status} status={deployment?.status}
className="size-2.5" className="size-2.5"

View File

@@ -85,8 +85,20 @@ export const AddDomain = ({
const form = useForm<Domain>({ const form = useForm<Domain>({
resolver: zodResolver(domain), resolver: zodResolver(domain),
defaultValues: {
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
},
mode: "onChange",
}); });
const certificateType = form.watch("certificateType");
const https = form.watch("https");
useEffect(() => { useEffect(() => {
if (data) { if (data) {
form.reset({ form.reset({
@@ -94,13 +106,29 @@ export const AddDomain = ({
/* Convert null to undefined */ /* Convert null to undefined */
path: data?.path || undefined, path: data?.path || undefined,
port: data?.port || undefined, port: data?.port || undefined,
certificateType: data?.certificateType || undefined,
customCertResolver: data?.customCertResolver || undefined,
}); });
} }
if (!domainId) { if (!domainId) {
form.reset({}); form.reset({
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
});
} }
}, [form, form.reset, data, isLoading]); }, [form, data, isLoading, domainId]);
// Separate effect for handling custom cert resolver validation
useEffect(() => {
if (certificateType === "custom") {
form.trigger("customCertResolver");
}
}, [certificateType, form]);
const dictionary = { const dictionary = {
success: domainId ? "Domain Updated" : "Domain Created", success: domainId ? "Domain Updated" : "Domain Created",
@@ -256,34 +284,73 @@ export const AddDomain = ({
)} )}
/> />
{form.getValues().https && ( {https && (
<>
<FormField <FormField
control={form.control} control={form.control}
name="certificateType" name="certificateType"
render={({ field }) => ( render={({ field }) => {
<FormItem className="col-span-2"> return (
<FormItem>
<FormLabel>Certificate Provider</FormLabel> <FormLabel>Certificate Provider</FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={(value) => {
defaultValue={field.value || ""} field.onChange(value);
if (value !== "custom") {
form.setValue(
"customCertResolver",
undefined,
);
}
}}
value={field.value}
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a certificate provider" /> <SelectValue placeholder="Select a certificate provider" />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="none">None</SelectItem> <SelectItem value={"none"}>None</SelectItem>
<SelectItem value={"letsencrypt"}> <SelectItem value={"letsencrypt"}>
Let's Encrypt Let's Encrypt
</SelectItem> </SelectItem>
<SelectItem value={"custom"}>Custom</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} );
}}
/> />
{certificateType === "custom" && (
<FormField
control={form.control}
name="customCertResolver"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Custom Certificate Resolver</FormLabel>
<FormControl>
<Input
className="w-full"
placeholder="Enter your custom certificate resolver"
{...field}
value={field.value || ""}
onChange={(e) => {
field.onChange(e);
form.trigger("customCertResolver");
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
)}
</>
)} )}
</div> </div>
</div> </div>

View File

@@ -18,7 +18,7 @@ import { Toggle } from "@/components/ui/toggle";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { EyeIcon, EyeOffIcon } from "lucide-react"; import { EyeIcon, EyeOffIcon } from "lucide-react";
import React, { type CSSProperties, useEffect, useState } from "react"; import { type CSSProperties, useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
@@ -71,15 +71,19 @@ export const ShowEnvironment = ({ id, type }: Props) => {
resolver: zodResolver(addEnvironmentSchema), resolver: zodResolver(addEnvironmentSchema),
}); });
// Watch form value
const currentEnvironment = form.watch("environment");
const hasChanges = currentEnvironment !== (data?.env || "");
useEffect(() => { useEffect(() => {
if (data) { if (data) {
form.reset({ form.reset({
environment: data.env || "", environment: data.env || "",
}); });
} }
}, [form.reset, data, form]); }, [data, form]);
const onSubmit = async (data: EnvironmentSchema) => { const onSubmit = async (formData: EnvironmentSchema) => {
mutateAsync({ mutateAsync({
mongoId: id || "", mongoId: id || "",
postgresId: id || "", postgresId: id || "",
@@ -87,7 +91,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
mysqlId: id || "", mysqlId: id || "",
mariadbId: id || "", mariadbId: id || "",
composeId: id || "", composeId: id || "",
env: data.environment, env: formData.environment,
}) })
.then(async () => { .then(async () => {
toast.success("Environments Added"); toast.success("Environments Added");
@@ -98,6 +102,12 @@ export const ShowEnvironment = ({ id, type }: Props) => {
}); });
}; };
const handleCancel = () => {
form.reset({
environment: data?.env || "",
});
};
return ( return (
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
<Card className="bg-background"> <Card className="bg-background">
@@ -106,6 +116,11 @@ export const ShowEnvironment = ({ id, type }: Props) => {
<CardTitle className="text-xl">Environment Settings</CardTitle> <CardTitle className="text-xl">Environment Settings</CardTitle>
<CardDescription> <CardDescription>
You can add environment variables to your resource. You can add environment variables to your resource.
{hasChanges && (
<span className="text-yellow-500 ml-2">
(You have unsaved changes)
</span>
)}
</CardDescription> </CardDescription>
</div> </div>
@@ -132,8 +147,8 @@ export const ShowEnvironment = ({ id, type }: Props) => {
control={form.control} control={form.control}
name="environment" name="environment"
render={({ field }) => ( render={({ field }) => (
<FormItem className="w-full"> <FormItem>
<FormControl> <FormControl className="">
<CodeEditor <CodeEditor
style={ style={
{ {
@@ -142,21 +157,35 @@ export const ShowEnvironment = ({ id, type }: Props) => {
} }
language="properties" language="properties"
disabled={isEnvVisible} disabled={isEnvVisible}
className="font-mono"
wrapperClassName="compose-file-editor"
placeholder={`NODE_ENV=production placeholder={`NODE_ENV=production
PORT=3000 PORT=3000
`} `}
className="h-96 font-mono"
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<div className="flex flex-row justify-end"> <div className="flex flex-row justify-end gap-2">
<Button isLoading={isLoading} className="w-fit" type="submit"> {hasChanges && (
<Button
type="button"
variant="outline"
onClick={handleCancel}
>
Cancel
</Button>
)}
<Button
isLoading={isLoading}
className="w-fit"
type="submit"
disabled={!hasChanges}
>
Save Save
</Button> </Button>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Form } from "@/components/ui/form"; import { Form } from "@/components/ui/form";
import { Secrets } from "@/components/ui/secrets"; import { Secrets } from "@/components/ui/secrets";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
@@ -7,6 +7,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { useEffect } from "react";
const addEnvironmentSchema = z.object({ const addEnvironmentSchema = z.object({
env: z.string(), env: z.string(),
@@ -34,16 +35,32 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
const form = useForm<EnvironmentSchema>({ const form = useForm<EnvironmentSchema>({
defaultValues: { defaultValues: {
env: data?.env || "", env: "",
buildArgs: data?.buildArgs || "", buildArgs: "",
}, },
resolver: zodResolver(addEnvironmentSchema), resolver: zodResolver(addEnvironmentSchema),
}); });
const onSubmit = async (data: EnvironmentSchema) => { // Watch form values
const currentEnv = form.watch("env");
const currentBuildArgs = form.watch("buildArgs");
const hasChanges =
currentEnv !== (data?.env || "") ||
currentBuildArgs !== (data?.buildArgs || "");
useEffect(() => {
if (data) {
form.reset({
env: data.env || "",
buildArgs: data.buildArgs || "",
});
}
}, [data, form]);
const onSubmit = async (formData: EnvironmentSchema) => {
mutateAsync({ mutateAsync({
env: data.env, env: formData.env,
buildArgs: data.buildArgs, buildArgs: formData.buildArgs,
applicationId, applicationId,
}) })
.then(async () => { .then(async () => {
@@ -55,6 +72,13 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
}); });
}; };
const handleCancel = () => {
form.reset({
env: data?.env || "",
buildArgs: data?.buildArgs || "",
});
};
return ( return (
<Card className="bg-background px-6 pb-6"> <Card className="bg-background px-6 pb-6">
<Form {...form}> <Form {...form}>
@@ -65,7 +89,16 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
<Secrets <Secrets
name="env" name="env"
title="Environment Settings" title="Environment Settings"
description="You can add environment variables to your resource." description={
<span>
You can add environment variables to your resource.
{hasChanges && (
<span className="text-yellow-500 ml-2">
(You have unsaved changes)
</span>
)}
</span>
}
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")} placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
/> />
{data?.buildType === "dockerfile" && ( {data?.buildType === "dockerfile" && (
@@ -89,8 +122,18 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
placeholder="NPM_TOKEN=xyz" placeholder="NPM_TOKEN=xyz"
/> />
)} )}
<div className="flex flex-row justify-end"> <div className="flex flex-row justify-end gap-2">
<Button isLoading={isLoading} className="w-fit" type="submit"> {hasChanges && (
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
)}
<Button
isLoading={isLoading}
className="w-fit"
type="submit"
disabled={!hasChanges}
>
Save Save
</Button> </Button>
</div> </div>

View File

@@ -29,14 +29,23 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown } from "lucide-react"; import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import Link from "next/link";
const BitbucketProviderSchema = z.object({ const BitbucketProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"), buildPath: z.string().min(1, "Path is required").default("/"),
@@ -48,6 +57,7 @@ const BitbucketProviderSchema = z.object({
.required(), .required(),
branch: z.string().min(1, "Branch is required"), branch: z.string().min(1, "Branch is required"),
bitbucketId: z.string().min(1, "Bitbucket Provider is required"), bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
watchPaths: z.array(z.string()).optional(),
}); });
type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>; type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>;
@@ -73,6 +83,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
}, },
bitbucketId: "", bitbucketId: "",
branch: "", branch: "",
watchPaths: [],
}, },
resolver: zodResolver(BitbucketProviderSchema), resolver: zodResolver(BitbucketProviderSchema),
}); });
@@ -84,7 +95,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
data: repositories, data: repositories,
isLoading: isLoadingRepositories, isLoading: isLoadingRepositories,
error, error,
isError,
} = api.bitbucket.getBitbucketRepositories.useQuery( } = api.bitbucket.getBitbucketRepositories.useQuery(
{ {
bitbucketId, bitbucketId,
@@ -119,6 +129,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
}, },
buildPath: data.bitbucketBuildPath || "/", buildPath: data.bitbucketBuildPath || "/",
bitbucketId: data.bitbucketId || "", bitbucketId: data.bitbucketId || "",
watchPaths: data.watchPaths || [],
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
@@ -131,6 +142,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
bitbucketBuildPath: data.buildPath, bitbucketBuildPath: data.buildPath,
bitbucketId: data.bitbucketId, bitbucketId: data.bitbucketId,
applicationId, applicationId,
watchPaths: data.watchPaths || [],
}) })
.then(async () => { .then(async () => {
toast.success("Service Provided Saved"); toast.success("Service Provided Saved");
@@ -196,7 +208,20 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
name="repository" name="repository"
render={({ field }) => ( render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col"> <FormItem className="md:col-span-2 flex flex-col">
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel> <FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://bitbucket.org/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<BitbucketIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<FormControl> <FormControl>
@@ -364,6 +389,84 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent>
<p>
Add paths to watch for changes. When files in these
paths change, a new deployment will be triggered.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
</Badge>
))}
</div>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}}
>
Add
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
<div className="flex w-full justify-end"> <div className="flex w-full justify-end">
<Button <Button

View File

@@ -115,7 +115,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
<FormItem> <FormItem>
<FormLabel>Username</FormLabel> <FormLabel>Username</FormLabel>
<FormControl> <FormControl>
<Input placeholder="username" {...field} /> <Input placeholder="Username" autoComplete="username" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -130,7 +130,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
<FormItem> <FormItem>
<FormLabel>Password</FormLabel> <FormLabel>Password</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Password" {...field} type="password" /> <Input placeholder="Password" autoComplete="one-time-code" {...field} type="password" />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@@ -17,23 +17,33 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { KeyRoundIcon, LockIcon } from "lucide-react"; import { KeyRoundIcon, LockIcon, X } from "lucide-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { GitIcon } from "@/components/icons/data-tools-icons";
const GitProviderSchema = z.object({ const GitProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"),
repositoryURL: z.string().min(1, { repositoryURL: z.string().min(1, {
message: "Repository URL is required", message: "Repository URL is required",
}), }),
branch: z.string().min(1, "Branch required"), branch: z.string().min(1, "Branch required"),
buildPath: z.string().min(1, "Build Path required"),
sshKey: z.string().optional(), sshKey: z.string().optional(),
watchPaths: z.array(z.string()).optional(),
}); });
type GitProvider = z.infer<typeof GitProviderSchema>; type GitProvider = z.infer<typeof GitProviderSchema>;
@@ -56,6 +66,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
buildPath: "/", buildPath: "/",
repositoryURL: "", repositoryURL: "",
sshKey: undefined, sshKey: undefined,
watchPaths: [],
}, },
resolver: zodResolver(GitProviderSchema), resolver: zodResolver(GitProviderSchema),
}); });
@@ -67,6 +78,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
branch: data.customGitBranch || "", branch: data.customGitBranch || "",
buildPath: data.customGitBuildPath || "/", buildPath: data.customGitBuildPath || "/",
repositoryURL: data.customGitUrl || "", repositoryURL: data.customGitUrl || "",
watchPaths: data.watchPaths || [],
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
@@ -78,6 +90,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
customGitUrl: values.repositoryURL, customGitUrl: values.repositoryURL,
customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey, customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey,
applicationId, applicationId,
watchPaths: values.watchPaths || [],
}) })
.then(async () => { .then(async () => {
toast.success("Git Provider Saved"); toast.success("Git Provider Saved");
@@ -102,9 +115,22 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
name="repositoryURL" name="repositoryURL"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className="flex items-center justify-between">
<FormLabel>Repository URL</FormLabel> <FormLabel>Repository URL</FormLabel>
{field.value?.startsWith("https://") && (
<Link
href={field.value}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<GitIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<FormControl> <FormControl>
<Input placeholder="git@bitbucket.org" {...field} /> <Input placeholder="Repository URL" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -160,6 +186,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
</Button> </Button>
)} )}
</div> </div>
<div className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="branch" name="branch"
@@ -173,6 +200,8 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
</div>
<FormField <FormField
control={form.control} control={form.control}
name="buildPath" name="buildPath"
@@ -186,6 +215,85 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p>
Add paths to watch for changes. When files in these
paths change, a new deployment will be triggered. This
will work only when manual webhook is setup.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
</Badge>
))}
</div>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}}
>
Add
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
<div className="flex flex-row justify-end"> <div className="flex flex-row justify-end">

View File

@@ -28,14 +28,23 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown } from "lucide-react"; import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import Link from "next/link";
import { GithubIcon } from "@/components/icons/data-tools-icons";
const GithubProviderSchema = z.object({ const GithubProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"), buildPath: z.string().min(1, "Path is required").default("/"),
@@ -47,6 +56,7 @@ const GithubProviderSchema = z.object({
.required(), .required(),
branch: z.string().min(1, "Branch is required"), branch: z.string().min(1, "Branch is required"),
githubId: z.string().min(1, "Github Provider is required"), githubId: z.string().min(1, "Github Provider is required"),
watchPaths: z.array(z.string()).optional(),
}); });
type GithubProvider = z.infer<typeof GithubProviderSchema>; type GithubProvider = z.infer<typeof GithubProviderSchema>;
@@ -113,6 +123,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
}, },
buildPath: data.buildPath || "/", buildPath: data.buildPath || "/",
githubId: data.githubId || "", githubId: data.githubId || "",
watchPaths: data.watchPaths || [],
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
@@ -125,6 +136,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
owner: data.repository.owner, owner: data.repository.owner,
buildPath: data.buildPath, buildPath: data.buildPath,
githubId: data.githubId, githubId: data.githubId,
watchPaths: data.watchPaths || [],
}) })
.then(async () => { .then(async () => {
toast.success("Service Provided Saved"); toast.success("Service Provided Saved");
@@ -187,7 +199,20 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
name="repository" name="repository"
render={({ field }) => ( render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col"> <FormItem className="md:col-span-2 flex flex-col">
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel> <FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://github.com/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<GithubIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<FormControl> <FormControl>
@@ -350,7 +375,85 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<FormControl> <FormControl>
<Input placeholder="/" {...field} /> <Input placeholder="/" {...field} />
</FormControl> </FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
<p>
Add paths to watch for changes. When files in these
paths change, a new deployment will be triggered.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge
key={index}
variant="secondary"
className="flex items-center gap-1"
>
{path}
<X
className="size-3 cursor-pointer hover:text-destructive"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
field.onChange(newPaths);
}}
/>
</Badge>
))}
</div>
<div className="flex gap-2">
<FormControl>
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const path = input.value.trim();
if (path) {
field.onChange([...(field.value || []), path]);
input.value = "";
}
}
}}
/>
</FormControl>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
const input = document.querySelector(
'input[placeholder*="Enter a path"]',
) as HTMLInputElement;
const path = input.value.trim();
if (path) {
field.onChange([...(field.value || []), path]);
input.value = "";
}
}}
>
<Plus className="size-4" />
</Button>
</div>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -365,6 +468,16 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
Save Save
</Button> </Button>
</div> </div>
{/* create github link */}
<div className="flex w-full justify-end">
<Link
href={`https://github.com/${repository?.owner}/${repository?.repo}`}
target="_blank"
className="w-fit"
>
Repository
</Link>
</div>
</form> </form>
</Form> </Form>
</div> </div>

View File

@@ -29,14 +29,23 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown } from "lucide-react"; import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import Link from "next/link";
import { GitlabIcon } from "@/components/icons/data-tools-icons";
const GitlabProviderSchema = z.object({ const GitlabProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"), buildPath: z.string().min(1, "Path is required").default("/"),
@@ -50,6 +59,7 @@ const GitlabProviderSchema = z.object({
.required(), .required(),
branch: z.string().min(1, "Branch is required"), branch: z.string().min(1, "Branch is required"),
gitlabId: z.string().min(1, "Gitlab Provider is required"), gitlabId: z.string().min(1, "Gitlab Provider is required"),
watchPaths: z.array(z.string()).optional(),
}); });
type GitlabProvider = z.infer<typeof GitlabProviderSchema>; type GitlabProvider = z.infer<typeof GitlabProviderSchema>;
@@ -124,6 +134,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
}, },
buildPath: data.gitlabBuildPath || "/", buildPath: data.gitlabBuildPath || "/",
gitlabId: data.gitlabId || "", gitlabId: data.gitlabId || "",
watchPaths: data.watchPaths || [],
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
@@ -138,6 +149,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
applicationId, applicationId,
gitlabProjectId: data.repository.id, gitlabProjectId: data.repository.id,
gitlabPathNamespace: data.repository.gitlabPathNamespace, gitlabPathNamespace: data.repository.gitlabPathNamespace,
watchPaths: data.watchPaths || [],
}) })
.then(async () => { .then(async () => {
toast.success("Service Provided Saved"); toast.success("Service Provided Saved");
@@ -203,7 +215,20 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
name="repository" name="repository"
render={({ field }) => ( render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col"> <FormItem className="md:col-span-2 flex flex-col">
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel> <FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://gitlab.com/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<GitlabIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<FormControl> <FormControl>
@@ -375,7 +400,85 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<FormControl> <FormControl>
<Input placeholder="/" {...field} /> <Input placeholder="/" {...field} />
</FormControl> </FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
<p>
Add paths to watch for changes. When files in these
paths change, a new deployment will be triggered.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge
key={index}
variant="secondary"
className="flex items-center gap-1"
>
{path}
<X
className="size-3 cursor-pointer hover:text-destructive"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
field.onChange(newPaths);
}}
/>
</Badge>
))}
</div>
<div className="flex gap-2">
<FormControl>
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const path = input.value.trim();
if (path) {
field.onChange([...(field.value || []), path]);
input.value = "";
}
}
}}
/>
</FormControl>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
const input = document.querySelector(
'input[placeholder*="Enter a path"]',
) as HTMLInputElement;
const path = input.value.trim();
if (path) {
field.onChange([...(field.value || []), path]);
input.value = "";
}
}}
>
<Plus className="size-4" />
</Button>
</div>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}

View File

@@ -11,7 +11,7 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { GitBranch, LockIcon, UploadCloud } from "lucide-react"; import { GitBranch, UploadCloud } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { SaveBitbucketProvider } from "./save-bitbucket-provider"; import { SaveBitbucketProvider } from "./save-bitbucket-provider";

View File

@@ -4,10 +4,23 @@ import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Ban, CheckCircle2, Hammer, RefreshCcw, Terminal } from "lucide-react"; import {
Ban,
CheckCircle2,
Hammer,
HelpCircle,
RefreshCcw,
Terminal,
} from "lucide-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal"; import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
interface Props { interface Props {
@@ -28,8 +41,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
const { mutateAsync: stop, isLoading: isStopping } = const { mutateAsync: stop, isLoading: isStopping } =
api.application.stop.useMutation(); api.application.stop.useMutation();
const { mutateAsync: deploy, isLoading: isDeploying } = const { mutateAsync: deploy } = api.application.deploy.useMutation();
api.application.deploy.useMutation();
const { mutateAsync: reload, isLoading: isReloading } = const { mutateAsync: reload, isLoading: isReloading } =
api.application.reload.useMutation(); api.application.reload.useMutation();
@@ -43,6 +55,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
<CardTitle className="text-xl">Deploy Settings</CardTitle> <CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap"> <CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}>
<DialogAction <DialogAction
title="Deploy Application" title="Deploy Application"
description="Are you sure you want to deploy this application?" description="Are you sure you want to deploy this application?"
@@ -66,8 +79,21 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
<Button <Button
variant="default" variant="default"
isLoading={data?.applicationStatus === "running"} isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5"
> >
Deploy Deploy
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Downloads the source code and performs a complete build
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
<DialogAction <DialogAction
@@ -113,9 +139,23 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
<Button <Button
variant="secondary" variant="secondary"
isLoading={data?.applicationStatus === "running"} isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5"
> >
Rebuild Rebuild
<Hammer className="size-4" /> <Hammer className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Only rebuilds the application without downloading new
code
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
@@ -137,9 +177,26 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
}); });
}} }}
> >
<Button variant="secondary" isLoading={isStarting}> <Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5"
>
Start Start
<CheckCircle2 className="size-4" /> <CheckCircle2 className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the application (requires a previous successful
build)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
) : ( ) : (
@@ -159,12 +216,27 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
}); });
}} }}
> >
<Button variant="destructive" isLoading={isStopping}> <Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5"
>
Stop Stop
<Ban className="size-4" /> <Ban className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running application</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
)} )}
</TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}
serverId={data?.serverId || ""} serverId={data?.serverId || ""}

View File

@@ -94,6 +94,7 @@ export const AddPreviewDomain = ({
/* Convert null to undefined */ /* Convert null to undefined */
path: data?.path || undefined, path: data?.path || undefined,
port: data?.port || undefined, port: data?.port || undefined,
customCertResolver: data?.customCertResolver || undefined,
}); });
} }

View File

@@ -5,7 +5,6 @@ import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,

View File

@@ -22,7 +22,6 @@ import {
RocketIcon, RocketIcon,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import React from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs"; import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
import { AddPreviewDomain } from "./add-preview-domain"; import { AddPreviewDomain } from "./add-preview-domain";

View File

@@ -35,7 +35,8 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
const schema = z.object({ const schema = z
.object({
env: z.string(), env: z.string(),
buildArgs: z.string(), buildArgs: z.string(),
wildcardDomain: z.string(), wildcardDomain: z.string(),
@@ -43,8 +44,21 @@ const schema = z.object({
previewLimit: z.number(), previewLimit: z.number(),
previewHttps: z.boolean(), previewHttps: z.boolean(),
previewPath: z.string(), previewPath: z.string(),
previewCertificateType: z.enum(["letsencrypt", "none"]), previewCertificateType: z.enum(["letsencrypt", "none", "custom"]),
}); previewCustomCertResolver: z.string().optional(),
})
.superRefine((input, ctx) => {
if (
input.previewCertificateType === "custom" &&
!input.previewCustomCertResolver
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["previewCustomCertResolver"],
message: "Required",
});
}
});
type Schema = z.infer<typeof schema>; type Schema = z.infer<typeof schema>;
@@ -90,6 +104,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewHttps: data.previewHttps || false, previewHttps: data.previewHttps || false,
previewPath: data.previewPath || "/", previewPath: data.previewPath || "/",
previewCertificateType: data.previewCertificateType || "none", previewCertificateType: data.previewCertificateType || "none",
previewCustomCertResolver: data.previewCustomCertResolver || "",
}); });
} }
}, [data]); }, [data]);
@@ -105,6 +120,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewHttps: formData.previewHttps, previewHttps: formData.previewHttps,
previewPath: formData.previewPath, previewPath: formData.previewPath,
previewCertificateType: formData.previewCertificateType, previewCertificateType: formData.previewCertificateType,
previewCustomCertResolver: formData.previewCustomCertResolver,
}) })
.then(() => { .then(() => {
toast.success("Preview Deployments settings updated"); toast.success("Preview Deployments settings updated");
@@ -184,10 +200,6 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Preview Limit</FormLabel> <FormLabel>Preview Limit</FormLabel>
{/* <FormDescription>
Set the limit of preview deployments that can be
created for this app.
</FormDescription> */}
<FormControl> <FormControl>
<NumberInput placeholder="3000" {...field} /> <NumberInput placeholder="3000" {...field} />
</FormControl> </FormControl>
@@ -238,6 +250,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
<SelectItem value={"letsencrypt"}> <SelectItem value={"letsencrypt"}>
Let's Encrypt Let's Encrypt
</SelectItem> </SelectItem>
<SelectItem value={"custom"}>Custom</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
@@ -245,6 +258,25 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
)} )}
/> />
)} )}
{form.watch("previewCertificateType") === "custom" && (
<FormField
control={form.control}
name="previewCustomCertResolver"
render={({ field }) => (
<FormItem>
<FormLabel>Certificate Provider</FormLabel>
<FormControl>
<Input
placeholder="my-custom-resolver"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div> </div>
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
<div className="flex flex-row items-center justify-between rounded-lg border p-4 col-span-2"> <div className="flex flex-row items-center justify-between rounded-lg border p-4 col-span-2">
@@ -279,7 +311,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
<FormField <FormField
control={form.control} control={form.control}
name="env" name="env"
render={({ field }) => ( render={() => (
<FormItem> <FormItem>
<FormControl> <FormControl>
<Secrets <Secrets

View File

@@ -21,7 +21,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, PenBoxIcon, SquarePen } from "lucide-react"; import { PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";

View File

@@ -19,7 +19,6 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import React from "react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";

View File

@@ -60,7 +60,7 @@ export const DeleteService = ({ id, type }: Props) => {
compose: () => compose: () =>
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }), api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
}; };
const { data, refetch } = queryMap[type] const { data } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });

View File

@@ -11,7 +11,6 @@ import {
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { RefreshCcw } from "lucide-react"; import { RefreshCcw } from "lucide-react";
import React from "react";
import { toast } from "sonner"; import { toast } from "sonner";
interface Props { interface Props {

View File

@@ -104,6 +104,15 @@ export const AddDomainCompose = ({
const form = useForm<Domain>({ const form = useForm<Domain>({
resolver: zodResolver(domainCompose), resolver: zodResolver(domainCompose),
defaultValues: {
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
serviceName: "",
},
}); });
const https = form.watch("https"); const https = form.watch("https");
@@ -116,11 +125,21 @@ export const AddDomainCompose = ({
path: data?.path || undefined, path: data?.path || undefined,
port: data?.port || undefined, port: data?.port || undefined,
serviceName: data?.serviceName || undefined, serviceName: data?.serviceName || undefined,
certificateType: data?.certificateType || undefined,
customCertResolver: data?.customCertResolver || undefined,
}); });
} }
if (!domainId) { if (!domainId) {
form.reset({}); form.reset({
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
serviceName: "",
});
} }
}, [form, form.reset, data, isLoading]); }, [form, form.reset, data, isLoading]);
@@ -393,6 +412,7 @@ export const AddDomainCompose = ({
/> />
{https && ( {https && (
<>
<FormField <FormField
control={form.control} control={form.control}
name="certificateType" name="certificateType"
@@ -414,12 +434,33 @@ export const AddDomainCompose = ({
<SelectItem value={"letsencrypt"}> <SelectItem value={"letsencrypt"}>
Let's Encrypt Let's Encrypt
</SelectItem> </SelectItem>
<SelectItem value={"custom"}>Custom</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{form.getValues().certificateType === "custom" && (
<FormField
control={form.control}
name="customCertResolver"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Custom Certificate Resolver</FormLabel>
<FormControl>
<Input
placeholder="Enter your custom certificate resolver"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)} )}
</div> </div>
</div> </div>

View File

@@ -118,7 +118,7 @@ export const ShowDomainsCompose = ({ composeId }: Props) => {
await deleteDomain({ await deleteDomain({
domainId: item.domainId, domainId: item.domainId,
}) })
.then((data) => { .then((_data) => {
refetch(); refetch();
toast.success("Domain deleted successfully"); toast.success("Domain deleted successfully");
}) })

View File

@@ -1,8 +1,15 @@
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Ban, CheckCircle2, Hammer, Terminal } from "lucide-react"; import { Ban, CheckCircle2, Hammer, HelpCircle, Terminal } from "lucide-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { toast } from "sonner"; import { toast } from "sonner";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal"; import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
@@ -27,6 +34,7 @@ export const ComposeActions = ({ composeId }: Props) => {
api.compose.stop.useMutation(); api.compose.stop.useMutation();
return ( return (
<div className="flex flex-row gap-4 w-full flex-wrap "> <div className="flex flex-row gap-4 w-full flex-wrap ">
<TooltipProvider delayDuration={0}>
<DialogAction <DialogAction
title="Deploy Compose" title="Deploy Compose"
description="Are you sure you want to deploy this compose?" description="Are you sure you want to deploy this compose?"
@@ -47,8 +55,22 @@ export const ComposeActions = ({ composeId }: Props) => {
}); });
}} }}
> >
<Button variant="default" isLoading={data?.composeStatus === "running"}> <Button
variant="default"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5"
>
Deploy Deploy
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads the source code and performs a complete build</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
<DialogAction <DialogAction
@@ -71,9 +93,20 @@ export const ComposeActions = ({ composeId }: Props) => {
<Button <Button
variant="secondary" variant="secondary"
isLoading={data?.composeStatus === "running"} isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5"
> >
Rebuild Rebuild
<Hammer className="size-4" /> <Hammer className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Only rebuilds the compose without downloading new code</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
{data?.composeType === "docker-compose" && {data?.composeType === "docker-compose" &&
@@ -95,9 +128,25 @@ export const ComposeActions = ({ composeId }: Props) => {
}); });
}} }}
> >
<Button variant="secondary" isLoading={isStarting}> <Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5"
>
Start Start
<CheckCircle2 className="size-4" /> <CheckCircle2 className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the compose (requires a previous successful build)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
) : ( ) : (
@@ -117,13 +166,27 @@ export const ComposeActions = ({ composeId }: Props) => {
}); });
}} }}
> >
<Button variant="destructive" isLoading={isStopping}> <Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5"
>
Stop Stop
<Ban className="size-4" /> <Ban className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running compose</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
)} )}
</TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}
serverId={data?.serverId || ""} serverId={data?.serverId || ""}

View File

@@ -35,8 +35,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
{ enabled: !!composeId }, { enabled: !!composeId },
); );
const { mutateAsync, isLoading, error, isError } = const { mutateAsync, isLoading } = api.compose.update.useMutation();
api.compose.update.useMutation();
const form = useForm<AddComposeFile>({ const form = useForm<AddComposeFile>({
defaultValues: { defaultValues: {
@@ -76,7 +75,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
composeId, composeId,
}); });
}) })
.catch((e) => { .catch((_e) => {
toast.error("Error updating the Compose config"); toast.error("Error updating the Compose config");
}); });
}; };

View File

@@ -29,14 +29,23 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown } from "lucide-react"; import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import Link from "next/link";
const BitbucketProviderSchema = z.object({ const BitbucketProviderSchema = z.object({
composePath: z.string().min(1), composePath: z.string().min(1),
@@ -48,6 +57,7 @@ const BitbucketProviderSchema = z.object({
.required(), .required(),
branch: z.string().min(1, "Branch is required"), branch: z.string().min(1, "Branch is required"),
bitbucketId: z.string().min(1, "Bitbucket Provider is required"), bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
watchPaths: z.array(z.string()).optional(),
}); });
type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>; type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>;
@@ -73,6 +83,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
}, },
bitbucketId: "", bitbucketId: "",
branch: "", branch: "",
watchPaths: [],
}, },
resolver: zodResolver(BitbucketProviderSchema), resolver: zodResolver(BitbucketProviderSchema),
}); });
@@ -84,7 +95,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
data: repositories, data: repositories,
isLoading: isLoadingRepositories, isLoading: isLoadingRepositories,
error, error,
isError,
} = api.bitbucket.getBitbucketRepositories.useQuery( } = api.bitbucket.getBitbucketRepositories.useQuery(
{ {
bitbucketId, bitbucketId,
@@ -119,6 +129,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
}, },
composePath: data.composePath, composePath: data.composePath,
bitbucketId: data.bitbucketId || "", bitbucketId: data.bitbucketId || "",
watchPaths: data.watchPaths || [],
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
@@ -133,6 +144,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
composeId, composeId,
sourceType: "bitbucket", sourceType: "bitbucket",
composeStatus: "idle", composeStatus: "idle",
watchPaths: data.watchPaths,
}) })
.then(async () => { .then(async () => {
toast.success("Service Provided Saved"); toast.success("Service Provided Saved");
@@ -198,7 +210,20 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
name="repository" name="repository"
render={({ field }) => ( render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col"> <FormItem className="md:col-span-2 flex flex-col">
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel> <FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://bitbucket.org/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<BitbucketIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<FormControl> <FormControl>
@@ -366,6 +391,84 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent>
<p>
Add paths to watch for changes. When files in these
paths change, a new deployment will be triggered.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
</Badge>
))}
</div>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}}
>
Add
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
<div className="flex w-full justify-end"> <div className="flex w-full justify-end">
<Button <Button

View File

@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Form, Form,
@@ -17,14 +18,22 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { KeyRoundIcon, LockIcon } from "lucide-react"; import { KeyRoundIcon, LockIcon, X } from "lucide-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { GitIcon } from "@/components/icons/data-tools-icons";
import Link from "next/link";
const GitProviderSchema = z.object({ const GitProviderSchema = z.object({
composePath: z.string().min(1), composePath: z.string().min(1),
@@ -33,6 +42,7 @@ const GitProviderSchema = z.object({
}), }),
branch: z.string().min(1, "Branch required"), branch: z.string().min(1, "Branch required"),
sshKey: z.string().optional(), sshKey: z.string().optional(),
watchPaths: z.array(z.string()).optional(),
}); });
type GitProvider = z.infer<typeof GitProviderSchema>; type GitProvider = z.infer<typeof GitProviderSchema>;
@@ -54,6 +64,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
repositoryURL: "", repositoryURL: "",
composePath: "./docker-compose.yml", composePath: "./docker-compose.yml",
sshKey: undefined, sshKey: undefined,
watchPaths: [],
}, },
resolver: zodResolver(GitProviderSchema), resolver: zodResolver(GitProviderSchema),
}); });
@@ -65,6 +76,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
branch: data.customGitBranch || "", branch: data.customGitBranch || "",
repositoryURL: data.customGitUrl || "", repositoryURL: data.customGitUrl || "",
composePath: data.composePath, composePath: data.composePath,
watchPaths: data.watchPaths || [],
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
@@ -77,6 +89,8 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
composeId, composeId,
sourceType: "git", sourceType: "git",
composePath: values.composePath, composePath: values.composePath,
composeStatus: "idle",
watchPaths: values.watchPaths || [],
}) })
.then(async () => { .then(async () => {
toast.success("Git Provider Saved"); toast.success("Git Provider Saved");
@@ -101,11 +115,22 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
name="repositoryURL" name="repositoryURL"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="flex flex-row justify-between"> <div className="flex items-center justify-between">
Repository URL <FormLabel>Repository URL</FormLabel>
</FormLabel> {field.value?.startsWith("https://") && (
<Link
href={field.value}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<GitIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<FormControl> <FormControl>
<Input placeholder="git@bitbucket.org" {...field} /> <Input placeholder="Repository URL" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -191,6 +216,85 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p>
Add paths to watch for changes. When files in these
paths change, a new deployment will be triggered. This
will work only when manual webhook is setup.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
</Badge>
))}
</div>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}}
>
Add
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
<div className="flex flex-row justify-end"> <div className="flex flex-row justify-end">

View File

@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Command, Command,
@@ -28,14 +29,22 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown } from "lucide-react"; import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { GithubIcon } from "@/components/icons/data-tools-icons";
import Link from "next/link";
const GithubProviderSchema = z.object({ const GithubProviderSchema = z.object({
composePath: z.string().min(1), composePath: z.string().min(1),
@@ -47,6 +56,7 @@ const GithubProviderSchema = z.object({
.required(), .required(),
branch: z.string().min(1, "Branch is required"), branch: z.string().min(1, "Branch is required"),
githubId: z.string().min(1, "Github Provider is required"), githubId: z.string().min(1, "Github Provider is required"),
watchPaths: z.array(z.string()).optional(),
}); });
type GithubProvider = z.infer<typeof GithubProviderSchema>; type GithubProvider = z.infer<typeof GithubProviderSchema>;
@@ -71,6 +81,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
}, },
githubId: "", githubId: "",
branch: "", branch: "",
watchPaths: [],
}, },
resolver: zodResolver(GithubProviderSchema), resolver: zodResolver(GithubProviderSchema),
}); });
@@ -113,6 +124,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
}, },
composePath: data.composePath, composePath: data.composePath,
githubId: data.githubId || "", githubId: data.githubId || "",
watchPaths: data.watchPaths || [],
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
@@ -127,6 +139,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
githubId: data.githubId, githubId: data.githubId,
sourceType: "github", sourceType: "github",
composeStatus: "idle", composeStatus: "idle",
watchPaths: data.watchPaths,
}) })
.then(async () => { .then(async () => {
toast.success("Service Provided Saved"); toast.success("Service Provided Saved");
@@ -183,13 +196,25 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="repository" name="repository"
render={({ field }) => ( render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col"> <FormItem className="md:col-span-2 flex flex-col">
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel> <FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://github.com/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<GithubIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<FormControl> <FormControl>
@@ -357,6 +382,84 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent>
<p>
Add paths to watch for changes. When files in these
paths change, a new deployment will be triggered.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
</Badge>
))}
</div>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}}
>
Add
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
<div className="flex w-full justify-end"> <div className="flex w-full justify-end">
<Button <Button

View File

@@ -29,14 +29,23 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown } from "lucide-react"; import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { GitlabIcon } from "@/components/icons/data-tools-icons";
import Link from "next/link";
const GitlabProviderSchema = z.object({ const GitlabProviderSchema = z.object({
composePath: z.string().min(1), composePath: z.string().min(1),
@@ -50,6 +59,7 @@ const GitlabProviderSchema = z.object({
.required(), .required(),
branch: z.string().min(1, "Branch is required"), branch: z.string().min(1, "Branch is required"),
gitlabId: z.string().min(1, "Gitlab Provider is required"), gitlabId: z.string().min(1, "Gitlab Provider is required"),
watchPaths: z.array(z.string()).optional(),
}); });
type GitlabProvider = z.infer<typeof GitlabProviderSchema>; type GitlabProvider = z.infer<typeof GitlabProviderSchema>;
@@ -76,6 +86,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
}, },
gitlabId: "", gitlabId: "",
branch: "", branch: "",
watchPaths: [],
}, },
resolver: zodResolver(GitlabProviderSchema), resolver: zodResolver(GitlabProviderSchema),
}); });
@@ -124,6 +135,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
}, },
composePath: data.composePath, composePath: data.composePath,
gitlabId: data.gitlabId || "", gitlabId: data.gitlabId || "",
watchPaths: data.watchPaths || [],
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
@@ -140,6 +152,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
gitlabPathNamespace: data.repository.gitlabPathNamespace, gitlabPathNamespace: data.repository.gitlabPathNamespace,
sourceType: "gitlab", sourceType: "gitlab",
composeStatus: "idle", composeStatus: "idle",
watchPaths: data.watchPaths,
}) })
.then(async () => { .then(async () => {
toast.success("Service Provided Saved"); toast.success("Service Provided Saved");
@@ -199,13 +212,25 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="repository" name="repository"
render={({ field }) => ( render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col"> <FormItem className="md:col-span-2 flex flex-col">
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel> <FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://gitlab.com/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<GitlabIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<FormControl> <FormControl>
@@ -382,6 +407,84 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent>
<p>
Add paths to watch for changes. When files in these
paths change, a new deployment will be triggered.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
</Badge>
))}
</div>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}}
>
Add
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
<div className="flex w-full justify-end"> <div className="flex w-full justify-end">
<Button <Button

View File

@@ -7,7 +7,7 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { CodeIcon, GitBranch, LockIcon } from "lucide-react"; import { CodeIcon, GitBranch } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { ComposeFileEditor } from "../compose-file-editor"; import { ComposeFileEditor } from "../compose-file-editor";

View File

@@ -70,7 +70,7 @@ export const IsolatedDeployment = ({ composeId }: Props) => {
composeId, composeId,
isolatedDeployment: formData?.isolatedDeployment || false, isolatedDeployment: formData?.isolatedDeployment || false,
}) })
.then(async (data) => { .then(async (_data) => {
randomizeCompose(); randomizeCompose();
refetch(); refetch();
toast.success("Compose updated"); toast.success("Compose updated");
@@ -147,9 +147,9 @@ export const IsolatedDeployment = ({ composeId }: Props) => {
render={({ field }) => ( render={({ field }) => (
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> <FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5"> <div className="space-y-0.5">
<FormLabel>Enable Randomize ({data?.appName})</FormLabel> <FormLabel>Enable Isolated Deployment ({data?.appName})</FormLabel>
<FormDescription> <FormDescription>
Enable randomize to the compose file. Enable isolated deployment to the compose file.
</FormDescription> </FormDescription>
</div> </div>
<FormControl> <FormControl>

View File

@@ -19,7 +19,7 @@ import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, Dices } from "lucide-react"; import { AlertTriangle } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -39,7 +39,7 @@ type Schema = z.infer<typeof schema>;
export const RandomizeCompose = ({ composeId }: Props) => { export const RandomizeCompose = ({ composeId }: Props) => {
const utils = api.useUtils(); const utils = api.useUtils();
const [compose, setCompose] = useState<string>(""); const [compose, setCompose] = useState<string>("");
const [isOpen, setIsOpen] = useState(false); const [_isOpen, _setIsOpen] = useState(false);
const { mutateAsync, error, isError } = const { mutateAsync, error, isError } =
api.compose.randomizeCompose.useMutation(); api.compose.randomizeCompose.useMutation();
@@ -76,7 +76,7 @@ export const RandomizeCompose = ({ composeId }: Props) => {
suffix: formData?.suffix || "", suffix: formData?.suffix || "",
randomize: formData?.randomize || false, randomize: formData?.randomize || false,
}) })
.then(async (data) => { .then(async (_data) => {
randomizeCompose(); randomizeCompose();
refetch(); refetch();
toast.success("Compose updated"); toast.success("Compose updated");

View File

@@ -40,7 +40,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
.then(() => { .then(() => {
refetch(); refetch();
}) })
.catch((err) => {}); .catch((_err) => {});
} }
}, [isOpen]); }, [isOpen]);

View File

@@ -7,7 +7,6 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import React from "react";
import { ComposeActions } from "./actions"; import { ComposeActions } from "./actions";
import { ShowProviderFormCompose } from "./generic/show"; import { ShowProviderFormCompose } from "./generic/show";
interface Props { interface Props {

View File

@@ -18,7 +18,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Loader, Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export const DockerLogs = dynamic( export const DockerLogs = dynamic(

View File

@@ -54,6 +54,7 @@ const AddPostgresBackup1Schema = z.object({
prefix: z.string().min(1, "Prefix required"), prefix: z.string().min(1, "Prefix required"),
enabled: z.boolean(), enabled: z.boolean(),
database: z.string().min(1, "Database required"), database: z.string().min(1, "Database required"),
keepLatestCount: z.coerce.number().optional(),
}); });
type AddPostgresBackup = z.infer<typeof AddPostgresBackup1Schema>; type AddPostgresBackup = z.infer<typeof AddPostgresBackup1Schema>;
@@ -77,6 +78,7 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
enabled: true, enabled: true,
prefix: "/", prefix: "/",
schedule: "", schedule: "",
keepLatestCount: undefined,
}, },
resolver: zodResolver(AddPostgresBackup1Schema), resolver: zodResolver(AddPostgresBackup1Schema),
}); });
@@ -88,6 +90,7 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
enabled: true, enabled: true,
prefix: "/", prefix: "/",
schedule: "", schedule: "",
keepLatestCount: undefined,
}); });
}, [form, form.reset, form.formState.isSubmitSuccessful]); }, [form, form.reset, form.formState.isSubmitSuccessful]);
@@ -117,6 +120,7 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
schedule: data.schedule, schedule: data.schedule,
enabled: data.enabled, enabled: data.enabled,
database: data.database, database: data.database,
keepLatestCount: data.keepLatestCount,
databaseType, databaseType,
...getDatabaseId, ...getDatabaseId,
}) })
@@ -265,7 +269,7 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
<Input placeholder={"dokploy/"} {...field} /> <Input placeholder={"dokploy/"} {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Use if you want to storage in a specific path of your Use if you want to back up in a specific path of your
destination/bucket destination/bucket
</FormDescription> </FormDescription>
@@ -274,6 +278,24 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
); );
}} }}
/> />
<FormField
control={form.control}
name="keepLatestCount"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Keep the latest</FormLabel>
<FormControl>
<Input type="number" placeholder={"keeps all the backups if left empty"} {...field} />
</FormControl>
<FormDescription>
Optional. If provided, only keeps the latest N backups in the cloud.
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField <FormField
control={form.control} control={form.control}
name="enabled" name="enabled"

View File

@@ -16,17 +16,18 @@ import {
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { DatabaseBackup, Play, Trash2 } from "lucide-react"; import { DatabaseBackup, Play, Trash2 } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import React from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { ServiceType } from "../../application/advanced/show-resources"; import type { ServiceType } from "../../application/advanced/show-resources";
import { AddBackup } from "./add-backup"; import { AddBackup } from "./add-backup";
import { UpdateBackup } from "./update-backup"; import { UpdateBackup } from "./update-backup";
import { useState } from "react";
interface Props { interface Props {
id: string; id: string;
type: Exclude<ServiceType, "application" | "redis">; type: Exclude<ServiceType, "application" | "redis">;
} }
export const ShowBackups = ({ id, type }: Props) => { export const ShowBackups = ({ id, type }: Props) => {
const [activeManualBackup, setActiveManualBackup] = useState<string | undefined>();
const queryMap = { const queryMap = {
postgres: () => postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
@@ -107,7 +108,7 @@ export const ShowBackups = ({ id, type }: Props) => {
{postgres?.backups.map((backup) => ( {postgres?.backups.map((backup) => (
<div key={backup.backupId}> <div key={backup.backupId}>
<div className="flex w-full flex-col md:flex-row md:items-center justify-between gap-4 md:gap-10 border rounded-lg p-4"> <div className="flex w-full flex-col md:flex-row md:items-center justify-between gap-4 md:gap-10 border rounded-lg p-4">
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-5 flex-col gap-8"> <div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-6 flex-col gap-8">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="font-medium">Destination</span> <span className="font-medium">Destination</span>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
@@ -138,6 +139,12 @@ export const ShowBackups = ({ id, type }: Props) => {
{backup.enabled ? "Yes" : "No"} {backup.enabled ? "Yes" : "No"}
</span> </span>
</div> </div>
<div className="flex flex-col gap-1">
<span className="font-medium">Keep Latest</span>
<span className="text-sm text-muted-foreground">
{backup.keepLatestCount || 'All'}
</span>
</div>
</div> </div>
<div className="flex flex-row gap-4"> <div className="flex flex-row gap-4">
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
@@ -146,8 +153,9 @@ export const ShowBackups = ({ id, type }: Props) => {
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
isLoading={isManualBackup} isLoading={isManualBackup && activeManualBackup === backup.backupId}
onClick={async () => { onClick={async () => {
setActiveManualBackup(backup.backupId);
await manualBackup({ await manualBackup({
backupId: backup.backupId as string, backupId: backup.backupId as string,
}) })
@@ -161,6 +169,7 @@ export const ShowBackups = ({ id, type }: Props) => {
"Error creating the manual backup", "Error creating the manual backup",
); );
}); });
setActiveManualBackup(undefined);
}} }}
> >
<Play className="size-5 text-muted-foreground" /> <Play className="size-5 text-muted-foreground" />

View File

@@ -35,7 +35,7 @@ import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, PenBoxIcon, Pencil } from "lucide-react"; import { CheckIcon, ChevronsUpDown, PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -47,6 +47,7 @@ const UpdateBackupSchema = z.object({
prefix: z.string().min(1, "Prefix required"), prefix: z.string().min(1, "Prefix required"),
enabled: z.boolean(), enabled: z.boolean(),
database: z.string().min(1, "Database required"), database: z.string().min(1, "Database required"),
keepLatestCount: z.coerce.number().optional(),
}); });
type UpdateBackup = z.infer<typeof UpdateBackupSchema>; type UpdateBackup = z.infer<typeof UpdateBackupSchema>;
@@ -78,6 +79,7 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
enabled: true, enabled: true,
prefix: "/", prefix: "/",
schedule: "", schedule: "",
keepLatestCount: undefined,
}, },
resolver: zodResolver(UpdateBackupSchema), resolver: zodResolver(UpdateBackupSchema),
}); });
@@ -90,6 +92,7 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
enabled: backup.enabled || false, enabled: backup.enabled || false,
prefix: backup.prefix, prefix: backup.prefix,
schedule: backup.schedule, schedule: backup.schedule,
keepLatestCount: backup.keepLatestCount ? Number(backup.keepLatestCount) : undefined,
}); });
} }
}, [form, form.reset, backup]); }, [form, form.reset, backup]);
@@ -102,6 +105,7 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
schedule: data.schedule, schedule: data.schedule,
enabled: data.enabled, enabled: data.enabled,
database: data.database, database: data.database,
keepLatestCount: data.keepLatestCount as number | null,
}) })
.then(async () => { .then(async () => {
toast.success("Backup Updated"); toast.success("Backup Updated");
@@ -253,7 +257,7 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
<Input placeholder={"dokploy/"} {...field} /> <Input placeholder={"dokploy/"} {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Use if you want to storage in a specific path of your Use if you want to back up in a specific path of your
destination/bucket destination/bucket
</FormDescription> </FormDescription>
@@ -262,6 +266,24 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
); );
}} }}
/> />
<FormField
control={form.control}
name="keepLatestCount"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Keep the latest</FormLabel>
<FormControl>
<Input type="number" placeholder={"keeps all the backups if left empty"} {...field} />
</FormControl>
<FormDescription>
Optional. If provided, only keeps the latest N backups in the cloud.
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField <FormField
control={form.control} control={form.control}
name="enabled" name="enabled"

View File

@@ -15,7 +15,6 @@ import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CheckIcon } from "lucide-react"; import { CheckIcon } from "lucide-react";
import React from "react";
export type TimeFilter = "all" | "1h" | "6h" | "24h" | "168h" | "720h"; export type TimeFilter = "all" | "1h" | "6h" | "24h" | "168h" | "720h";

View File

@@ -9,7 +9,6 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { FancyAnsi } from "fancy-ansi"; import { FancyAnsi } from "fancy-ansi";
import { escapeRegExp } from "lodash"; import { escapeRegExp } from "lodash";
import React from "react";
import { type LogLine, getLogType } from "./utils"; import { type LogLine, getLogType } from "./utils";
interface LogLineProps { interface LogLineProps {

View File

@@ -1,6 +1,5 @@
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, MoreHorizontal } from "lucide-react"; import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {

View File

@@ -1,18 +1,3 @@
import {
type ColumnFiltersState,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { ChevronDown, Container } from "lucide-react";
import * as React from "react";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -37,6 +22,19 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { type RouterOutputs, api } from "@/utils/api"; import { type RouterOutputs, api } from "@/utils/api";
import {
type ColumnFiltersState,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { ChevronDown, Container } from "lucide-react";
import * as React from "react";
import { columns } from "./colums"; import { columns } from "./colums";
export type Container = NonNullable< export type Container = NonNullable<
RouterOutputs["docker"]["getContainers"] RouterOutputs["docker"]["getContainers"]

View File

@@ -7,9 +7,8 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Tree } from "@/components/ui/file-tree"; import { Tree } from "@/components/ui/file-tree";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { FileIcon, Folder, Link, Loader2, Workflow } from "lucide-react"; import { FileIcon, Folder, Loader2, Workflow } from "lucide-react";
import React from "react"; import React from "react";
import { ShowTraefikFile } from "./show-traefik-file"; import { ShowTraefikFile } from "./show-traefik-file";

View File

@@ -19,7 +19,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import React, { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";

View File

@@ -2,9 +2,22 @@ import { DialogAction } from "@/components/shared/dialog-action";
import { DrawerLogs } from "@/components/shared/drawer-logs"; import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Ban, CheckCircle2, RefreshCcw, Terminal } from "lucide-react"; import {
import React, { useState } from "react"; Ban,
CheckCircle2,
HelpCircle,
RefreshCcw,
Terminal,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { type LogLine, parseLogs } from "../../docker/logs/utils"; import { type LogLine, parseLogs } from "../../docker/logs/utils";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal"; import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
@@ -65,6 +78,7 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
<CardTitle className="text-xl">Deploy Settings</CardTitle> <CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap"> <CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}>
<DialogAction <DialogAction
title="Deploy Mariadb" title="Deploy Mariadb"
description="Are you sure you want to deploy this mariadb?" description="Are you sure you want to deploy this mariadb?"
@@ -78,8 +92,19 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
<Button <Button
variant="default" variant="default"
isLoading={data?.applicationStatus === "running"} isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5"
> >
Deploy Deploy
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads and sets up the MariaDB database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
<DialogAction <DialogAction
@@ -100,9 +125,23 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
}); });
}} }}
> >
<Button variant="secondary" isLoading={isReloading}> <Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5"
>
Reload Reload
<RefreshCcw className="size-4" /> <RefreshCcw className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MariaDB service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
{data?.applicationStatus === "idle" ? ( {data?.applicationStatus === "idle" ? (
@@ -123,9 +162,26 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
}); });
}} }}
> >
<Button variant="secondary" isLoading={isStarting}> <Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5"
>
Start Start
<CheckCircle2 className="size-4" /> <CheckCircle2 className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the MariaDB database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
) : ( ) : (
@@ -145,12 +201,27 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
}); });
}} }}
> >
<Button variant="destructive" isLoading={isStopping}> <Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5"
>
Stop Stop
<Ban className="size-4" /> <Ban className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running MariaDB database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
)} )}
</TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}
serverId={data?.serverId || ""} serverId={data?.serverId || ""}

View File

@@ -3,7 +3,6 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import React from "react";
interface Props { interface Props {
mariadbId: string; mariadbId: string;

View File

@@ -21,7 +21,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, PenBoxIcon, SquarePen } from "lucide-react"; import { PenBoxIcon } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";

View File

@@ -19,7 +19,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import React, { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";

View File

@@ -2,9 +2,22 @@ import { DialogAction } from "@/components/shared/dialog-action";
import { DrawerLogs } from "@/components/shared/drawer-logs"; import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Ban, CheckCircle2, RefreshCcw, Terminal } from "lucide-react"; import {
import React, { useState } from "react"; Ban,
CheckCircle2,
HelpCircle,
RefreshCcw,
Terminal,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { type LogLine, parseLogs } from "../../docker/logs/utils"; import { type LogLine, parseLogs } from "../../docker/logs/utils";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal"; import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
@@ -64,6 +77,7 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
<CardTitle className="text-xl">Deploy Settings</CardTitle> <CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap"> <CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}>
<DialogAction <DialogAction
title="Deploy Mongo" title="Deploy Mongo"
description="Are you sure you want to deploy this mongo?" description="Are you sure you want to deploy this mongo?"
@@ -77,8 +91,19 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
<Button <Button
variant="default" variant="default"
isLoading={data?.applicationStatus === "running"} isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5"
> >
Deploy Deploy
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads and sets up the MongoDB database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
<DialogAction <DialogAction
@@ -99,9 +124,23 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
}); });
}} }}
> >
<Button variant="secondary" isLoading={isReloading}> <Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5"
>
Reload Reload
<RefreshCcw className="size-4" /> <RefreshCcw className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MongoDB service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
{data?.applicationStatus === "idle" ? ( {data?.applicationStatus === "idle" ? (
@@ -122,16 +161,32 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
}); });
}} }}
> >
<Button variant="secondary" isLoading={isStarting}> <Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5"
>
Start Start
<CheckCircle2 className="size-4" /> <CheckCircle2 className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the MongoDB database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
) : ( ) : (
<DialogAction <DialogAction
title="Stop Mongo" title="Stop Mongo"
description="Are you sure you want to stop this mongo?" description="Are you sure you want to stop this mongo?"
type="default"
onClick={async () => { onClick={async () => {
await stop({ await stop({
mongoId: mongoId, mongoId: mongoId,
@@ -145,12 +200,27 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
}); });
}} }}
> >
<Button variant="destructive" isLoading={isStopping}> <Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5"
>
Stop Stop
<Ban className="size-4" /> <Ban className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running MongoDB database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
)} )}
</TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}
serverId={data?.serverId || ""} serverId={data?.serverId || ""}

View File

@@ -3,7 +3,6 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import React from "react";
interface Props { interface Props {
mongoId: string; mongoId: string;

View File

@@ -2,7 +2,6 @@ import { badgeStateColor } from "@/components/dashboard/application/logs/show";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,

View File

@@ -1,13 +1,7 @@
import { import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import React, { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { DockerBlockChart } from "./docker-block-chart"; import { DockerBlockChart } from "./docker-block-chart";
import { DockerCpuChart } from "./docker-cpu-chart"; import { DockerCpuChart } from "./docker-cpu-chart";
import { DockerDiskChart } from "./docker-disk-chart"; import { DockerDiskChart } from "./docker-disk-chart";
@@ -206,7 +200,7 @@ export const ContainerFreeMonitoring = ({
}, [appName]); }, [appName]);
return ( return (
<div className="rounded-xl bg-background shadow-md flex flex-col gap-4"> <div className="rounded-xl bg-background flex flex-col gap-4">
<header className="flex items-center justify-between"> <header className="flex items-center justify-between">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">Monitoring</h1> <h1 className="text-2xl font-semibold tracking-tight">Monitoring</h1>

View File

@@ -29,14 +29,6 @@ interface Props {
data: ContainerMetric[]; data: ContainerMetric[];
} }
interface FormattedMetric {
timestamp: string;
read: number;
write: number;
readUnit: string;
writeUnit: string;
}
const chartConfig = { const chartConfig = {
read: { read: {
label: "Read", label: "Read",

View File

@@ -79,7 +79,7 @@ export const ContainerPaidMonitoring = ({ appName, baseUrl, token }: Props) => {
data, data,
isLoading, isLoading,
error: queryError, error: queryError,
} = api.admin.getContainerMetrics.useQuery( } = api.user.getContainerMetrics.useQuery(
{ {
url: baseUrl, url: baseUrl,
token, token,

View File

@@ -7,7 +7,6 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Clock, Cpu, HardDrive, Loader2, MemoryStick } from "lucide-react"; import { Clock, Cpu, HardDrive, Loader2, MemoryStick } from "lucide-react";
import type React from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { CPUChart } from "./cpu-chart"; import { CPUChart } from "./cpu-chart";
import { DiskChart } from "./disk-chart"; import { DiskChart } from "./disk-chart";
@@ -73,7 +72,7 @@ export const ShowPaidMonitoring = ({
data, data,
isLoading, isLoading,
error: queryError, error: queryError,
} = api.admin.getServerMetrics.useQuery( } = api.server.getServerMetrics.useQuery(
{ {
url: BASE_URL, url: BASE_URL,
token, token,

View File

@@ -19,7 +19,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import React, { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";

View File

@@ -2,9 +2,22 @@ import { DialogAction } from "@/components/shared/dialog-action";
import { DrawerLogs } from "@/components/shared/drawer-logs"; import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Ban, CheckCircle2, RefreshCcw, Terminal } from "lucide-react"; import {
import React, { useState } from "react"; Ban,
CheckCircle2,
HelpCircle,
RefreshCcw,
Terminal,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { type LogLine, parseLogs } from "../../docker/logs/utils"; import { type LogLine, parseLogs } from "../../docker/logs/utils";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal"; import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
@@ -62,6 +75,7 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
<CardTitle className="text-xl">Deploy Settings</CardTitle> <CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap"> <CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}>
<DialogAction <DialogAction
title="Deploy Mysql" title="Deploy Mysql"
description="Are you sure you want to deploy this mysql?" description="Are you sure you want to deploy this mysql?"
@@ -75,8 +89,19 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
<Button <Button
variant="default" variant="default"
isLoading={data?.applicationStatus === "running"} isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5"
> >
Deploy Deploy
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads and sets up the MySQL database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
<DialogAction <DialogAction
@@ -97,9 +122,23 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
}); });
}} }}
> >
<Button variant="secondary" isLoading={isReloading}> <Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5"
>
Reload Reload
<RefreshCcw className="size-4" /> <RefreshCcw className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MySQL service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
{data?.applicationStatus === "idle" ? ( {data?.applicationStatus === "idle" ? (
@@ -120,9 +159,26 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
}); });
}} }}
> >
<Button variant="secondary" isLoading={isStarting}> <Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5"
>
Start Start
<CheckCircle2 className="size-4" /> <CheckCircle2 className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the MySQL database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
) : ( ) : (
@@ -142,13 +198,27 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
}); });
}} }}
> >
<Button variant="destructive" isLoading={isStopping}> <Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5"
>
Stop Stop
<Ban className="size-4" /> <Ban className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running MySQL database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
)} )}
</TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}
serverId={data?.serverId || ""} serverId={data?.serverId || ""}

View File

@@ -3,7 +3,6 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import React from "react";
interface Props { interface Props {
mysqlId: string; mysqlId: string;

View File

@@ -21,7 +21,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, PenBoxIcon, SquarePen } from "lucide-react"; import { PenBoxIcon } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";

View File

@@ -0,0 +1,182 @@
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 { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, Plus } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const organizationSchema = z.object({
name: z.string().min(1, {
message: "Organization name is required",
}),
logo: z.string().optional(),
});
type OrganizationFormValues = z.infer<typeof organizationSchema>;
interface Props {
organizationId?: string;
children?: React.ReactNode;
}
export function AddOrganization({ organizationId }: Props) {
const [open, setOpen] = useState(false);
const utils = api.useUtils();
const { data: organization } = api.organization.one.useQuery(
{
organizationId: organizationId ?? "",
},
{
enabled: !!organizationId,
},
);
const { mutateAsync, isLoading } = organizationId
? api.organization.update.useMutation()
: api.organization.create.useMutation();
const form = useForm<OrganizationFormValues>({
resolver: zodResolver(organizationSchema),
defaultValues: {
name: "",
logo: "",
},
});
useEffect(() => {
if (organization) {
form.reset({
name: organization.name,
logo: organization.logo || "",
});
}
}, [organization, form]);
const onSubmit = async (values: OrganizationFormValues) => {
await mutateAsync({
name: values.name,
logo: values.logo,
organizationId: organizationId ?? "",
})
.then(() => {
form.reset();
toast.success(
`Organization ${organizationId ? "updated" : "created"} successfully`,
);
utils.organization.all.invalidate();
setOpen(false);
})
.catch((error) => {
console.error(error);
toast.error(
`Failed to ${organizationId ? "update" : "create"} organization`,
);
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{organizationId ? (
<DropdownMenuItem
className="group cursor-pointer hover:bg-blue-500/10"
onSelect={(e) => e.preventDefault()}
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</DropdownMenuItem>
) : (
<DropdownMenuItem
className="gap-2 p-2"
onSelect={(e) => e.preventDefault()}
>
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
<Plus className="size-4" />
</div>
<div className="font-medium text-muted-foreground">
Add organization
</div>
</DropdownMenuItem>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>
{organizationId ? "Update organization" : "Add organization"}
</DialogTitle>
<DialogDescription>
{organizationId
? "Update the organization name and logo"
: "Create a new organization to manage your projects."}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid gap-4 py-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="tems-center gap-4">
<FormLabel className="text-right">Name</FormLabel>
<FormControl>
<Input
placeholder="Organization name"
{...field}
className="col-span-3"
/>
</FormControl>
<FormMessage className="" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="logo"
render={({ field }) => (
<FormItem className=" gap-4">
<FormLabel className="text-right">Logo URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/logo.png"
{...field}
value={field.value || ""}
className="col-span-3"
/>
</FormControl>
<FormMessage className="col-span-3 col-start-2" />
</FormItem>
)}
/>
<DialogFooter className="mt-4">
<Button type="submit" isLoading={isLoading}>
{organizationId ? "Update organization" : "Create organization"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -11,7 +11,7 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import React, { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
@@ -53,7 +53,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
mongo: () => api.mongo.update.useMutation(), mongo: () => api.mongo.update.useMutation(),
}; };
const { mutateAsync, isLoading } = mutationMap[type] const { mutateAsync } = mutationMap[type]
? mutationMap[type]() ? mutationMap[type]()
: api.mongo.update.useMutation(); : api.mongo.update.useMutation();

View File

@@ -19,7 +19,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import React, { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";

View File

@@ -2,12 +2,26 @@ import { DialogAction } from "@/components/shared/dialog-action";
import { DrawerLogs } from "@/components/shared/drawer-logs"; import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Ban, CheckCircle2, RefreshCcw, Terminal } from "lucide-react"; import {
import React, { useState } from "react"; Ban,
CheckCircle2,
HelpCircle,
RefreshCcw,
Terminal,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { type LogLine, parseLogs } from "../../docker/logs/utils"; import { type LogLine, parseLogs } from "../../docker/logs/utils";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal"; import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
interface Props { interface Props {
postgresId: string; postgresId: string;
} }
@@ -57,19 +71,20 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
); );
return ( return (
<>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
<Card className="bg-background"> <Card className="bg-background">
<CardHeader className="pb-4"> <CardHeader>
<CardTitle className="text-xl">General</CardTitle> <CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex gap-4"> <CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}>
<DialogAction <DialogAction
title="Deploy Postgres" title="Deploy Postgres"
description="Are you sure you want to deploy this postgres?" description="Are you sure you want to deploy this postgres?"
type="default" type="default"
onClick={async () => { onClick={async () => {
setIsDeploying(true); setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
refetch(); refetch();
}} }}
@@ -77,11 +92,21 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
<Button <Button
variant="default" variant="default"
isLoading={data?.applicationStatus === "running"} isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5"
> >
Deploy Deploy
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads and sets up the PostgreSQL database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
<DialogAction <DialogAction
title="Reload Postgres" title="Reload Postgres"
description="Are you sure you want to reload this postgres?" description="Are you sure you want to reload this postgres?"
@@ -100,9 +125,23 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
}); });
}} }}
> >
<Button variant="secondary" isLoading={isReloading}> <Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5"
>
Reload Reload
<RefreshCcw className="size-4" /> <RefreshCcw className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the PostgreSQL service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
{data?.applicationStatus === "idle" ? ( {data?.applicationStatus === "idle" ? (
@@ -123,9 +162,26 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
}); });
}} }}
> >
<Button variant="secondary" isLoading={isStarting}> <Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5"
>
Start Start
<CheckCircle2 className="size-4" /> <CheckCircle2 className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the PostgreSQL database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
) : ( ) : (
@@ -145,13 +201,27 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
}); });
}} }}
> >
<Button variant="destructive" isLoading={isStopping}> <Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5"
>
Stop Stop
<Ban className="size-4" /> <Ban className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running PostgreSQL database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
)} )}
</TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}
serverId={data?.serverId || ""} serverId={data?.serverId || ""}
@@ -174,5 +244,6 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
filteredLogs={filteredLogs} filteredLogs={filteredLogs}
/> />
</div> </div>
</>
); );
}; };

View File

@@ -3,7 +3,6 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import React from "react";
interface Props { interface Props {
postgresId: string; postgresId: string;

View File

@@ -21,7 +21,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, PenBoxIcon, SquarePen } from "lucide-react"; import { PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";

View File

@@ -0,0 +1,10 @@
import { TemplateGenerator } from "@/components/dashboard/project/ai/template-generator";
interface Props {
projectId: string;
projectName?: string;
}
export const AddAiAssistant = ({ projectId }: Props) => {
return <TemplateGenerator projectId={projectId} />;
};

View File

@@ -103,7 +103,7 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
projectId, projectId,
}); });
}) })
.catch((e) => { .catch((_e) => {
toast.error("Error creating the service"); toast.error("Error creating the service");
}); });
}; };

View File

@@ -18,7 +18,6 @@ import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@@ -49,7 +48,6 @@ import { z } from "zod";
type DbType = typeof mySchema._type.type; type DbType = typeof mySchema._type.type;
// TODO: Change to a real docker images
const dockerImageDefaultPlaceholder: Record<DbType, string> = { const dockerImageDefaultPlaceholder: Record<DbType, string> = {
mongo: "mongo:6", mongo: "mongo:6",
mariadb: "mariadb:11", mariadb: "mariadb:11",
@@ -496,7 +494,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
<Input <Input
type="password" type="password"
placeholder="******************" placeholder="******************"
autoComplete="off" autoComplete="one-time-code"
{...field} {...field}
/> />
</FormControl> </FormControl>

View File

@@ -57,7 +57,6 @@ import {
BookText, BookText,
CheckIcon, CheckIcon,
ChevronsUpDown, ChevronsUpDown,
Code,
Github, Github,
Globe, Globe,
HelpCircle, HelpCircle,
@@ -418,7 +417,7 @@ export const AddTemplate = ({ projectId }: Props) => {
side="top" side="top"
> >
<span> <span>
If ot server is selected, the application If no server is selected, the application
will be deployed on the server where the will be deployed on the server where the
user is logged in. user is logged in.
</span> </span>
@@ -469,7 +468,7 @@ export const AddTemplate = ({ projectId }: Props) => {
}); });
toast.promise(promise, { toast.promise(promise, {
loading: "Setting up...", loading: "Setting up...",
success: (data) => { success: (_data) => {
utils.project.one.invalidate({ utils.project.one.invalidate({
projectId, projectId,
}); });

View File

@@ -0,0 +1,102 @@
"use client";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { useState } from "react";
const examples = [
"Make a personal blog",
"Add a photo studio portfolio",
"Create a personal ad blocker",
"Build a social media dashboard",
"Sendgrid service opensource analogue",
];
export const StepOne = ({ nextStep, setTemplateInfo, templateInfo }: any) => {
// Get servers from the API
const { data: servers } = api.server.withSSHKey.useQuery();
const handleExampleClick = (example: string) => {
setTemplateInfo({ ...templateInfo, userInput: example });
};
return (
<div className="flex flex-col h-full gap-4">
<div className="">
<div className="space-y-4 ">
<h2 className="text-lg font-semibold">Step 1: Describe Your Needs</h2>
<div className="space-y-2">
<Label htmlFor="user-needs">Describe your template needs</Label>
<Textarea
id="user-needs"
placeholder="Describe the type of template you need, its purpose, and any specific features you'd like to include."
value={templateInfo?.userInput}
onChange={(e) =>
setTemplateInfo({ ...templateInfo, userInput: e.target.value })
}
className="min-h-[100px]"
/>
</div>
<div className="space-y-2">
<Label htmlFor="server-deploy">
Select the server where you want to deploy (optional)
</Label>
<Select
value={templateInfo.server?.serverId}
onValueChange={(value) => {
const server = servers?.find((s) => s.serverId === value);
if (server) {
setTemplateInfo({
...templateInfo,
server: server,
});
}
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a server" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{servers?.map((server) => (
<SelectItem key={server.serverId} value={server.serverId}>
{server.name}
</SelectItem>
))}
<SelectLabel>Servers ({servers?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Examples:</Label>
<div className="flex flex-wrap gap-2">
{examples.map((example, index) => (
<Button
key={index}
variant="outline"
size="sm"
onClick={() => handleExampleClick(example)}
>
{example}
</Button>
))}
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,109 @@
import { CodeEditor } from "@/components/shared/code-editor";
import ReactMarkdown from "react-markdown";
import type { StepProps } from "./step-two";
export const StepThree = ({ templateInfo }: StepProps) => {
return (
<div className="flex flex-col h-full">
<div className="flex-grow">
<div className="space-y-6">
<h2 className="text-lg font-semibold">Step 3: Review and Finalize</h2>
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold">Name</h3>
<p className="text-sm text-muted-foreground">
{templateInfo?.details?.name}
</p>
</div>
<div>
<h3 className="text-sm font-semibold">Description</h3>
<ReactMarkdown className="text-sm text-muted-foreground">
{templateInfo?.details?.description}
</ReactMarkdown>
</div>
<div>
<h3 className="text-md font-semibold">Server</h3>
<p className="text-sm text-muted-foreground">
{templateInfo?.server?.name || "Dokploy Server"}
</p>
</div>
<div className="space-y-2">
<h3 className="text-sm font-semibold">Docker Compose</h3>
<CodeEditor
lineWrapping
value={templateInfo?.details?.dockerCompose}
disabled
className="font-mono"
/>
</div>
<div>
<h3 className="text-sm font-semibold">Environment Variables</h3>
<ul className="list-disc pl-5">
{templateInfo?.details?.envVariables.map(
(
env: {
name: string;
value: string;
},
index: number,
) => (
<li key={index}>
<strong className="text-sm font-semibold">
{env.name}
</strong>
:
<span className="text-sm ml-2 text-muted-foreground">
{env.value}
</span>
</li>
),
)}
</ul>
</div>
<div>
<h3 className="text-sm font-semibold">Domains</h3>
<ul className="list-disc pl-5">
{templateInfo?.details?.domains.map(
(
domain: {
host: string;
port: number;
serviceName: string;
},
index: number,
) => (
<li key={index}>
<strong className="text-sm font-semibold">
{domain.host}
</strong>
:
<span className="text-sm ml-2 text-muted-foreground">
{domain.port} - {domain.serviceName}
</span>
</li>
),
)}
</ul>
</div>
<div>
<h3 className="text-sm font-semibold">Configuration Files</h3>
<ul className="list-disc pl-5">
{templateInfo?.details?.configFiles.map((file, index) => (
<li key={index}>
<strong className="text-sm font-semibold">
{file.filePath}
</strong>
:
<span className="text-sm ml-2 text-muted-foreground">
{file.content}
</span>
</li>
))}
</ul>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,530 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { ScrollArea } from "@/components/ui/scroll-area";
import { api } from "@/utils/api";
import { Bot, Eye, EyeOff, PlusCircle, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import { toast } from "sonner";
import type { TemplateInfo } from "./template-generator";
export interface StepProps {
stepper?: any;
templateInfo: TemplateInfo;
setTemplateInfo: React.Dispatch<React.SetStateAction<TemplateInfo>>;
}
export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
const suggestions = templateInfo.suggestions || [];
const selectedVariant = templateInfo.details;
const [showValues, setShowValues] = useState<Record<string, boolean>>({});
const { mutateAsync, isLoading, error, isError } =
api.ai.suggest.useMutation();
useEffect(() => {
if (suggestions?.length > 0) {
return;
}
mutateAsync({
aiId: templateInfo.aiId,
serverId: templateInfo.server?.serverId || "",
input: templateInfo.userInput,
})
.then((data) => {
setTemplateInfo({
...templateInfo,
suggestions: data,
});
})
.catch((error) => {
toast.error("Error generating suggestions", {
description: error.message,
});
});
}, [templateInfo.userInput]);
const toggleShowValue = (name: string) => {
setShowValues((prev) => ({ ...prev, [name]: !prev[name] }));
};
const handleEnvVariableChange = (
index: number,
field: "name" | "value",
value: string,
) => {
if (!selectedVariant) return;
const updatedEnvVariables = [...selectedVariant.envVariables];
// @ts-ignore
updatedEnvVariables[index] = {
...updatedEnvVariables[index],
[field]: value,
};
setTemplateInfo({
...templateInfo,
...(templateInfo.details && {
details: {
...templateInfo.details,
envVariables: updatedEnvVariables,
},
}),
});
};
const removeEnvVariable = (index: number) => {
if (!selectedVariant) return;
const updatedEnvVariables = selectedVariant.envVariables.filter(
(_, i) => i !== index,
);
setTemplateInfo({
...templateInfo,
...(templateInfo.details && {
details: {
...templateInfo.details,
envVariables: updatedEnvVariables,
},
}),
});
};
const handleDomainChange = (
index: number,
field: "host" | "port" | "serviceName",
value: string | number,
) => {
if (!selectedVariant) return;
const updatedDomains = [...selectedVariant.domains];
// @ts-ignore
updatedDomains[index] = {
...updatedDomains[index],
[field]: value,
};
setTemplateInfo({
...templateInfo,
...(templateInfo.details && {
details: {
...templateInfo.details,
domains: updatedDomains,
},
}),
});
};
const removeDomain = (index: number) => {
if (!selectedVariant) return;
const updatedDomains = selectedVariant.domains.filter(
(_, i) => i !== index,
);
setTemplateInfo({
...templateInfo,
...(templateInfo.details && {
details: {
...templateInfo.details,
domains: updatedDomains,
},
}),
});
};
const addEnvVariable = () => {
if (!selectedVariant) return;
setTemplateInfo({
...templateInfo,
...(templateInfo.details && {
details: {
...templateInfo.details,
envVariables: [
...selectedVariant.envVariables,
{ name: "", value: "" },
],
},
}),
});
};
const addDomain = () => {
if (!selectedVariant) return;
setTemplateInfo({
...templateInfo,
...(templateInfo.details && {
details: {
...templateInfo.details,
domains: [
...selectedVariant.domains,
{ host: "", port: 80, serviceName: "" },
],
},
}),
});
};
if (isError) {
return (
<div className="flex flex-col items-center justify-center h-full space-y-4">
<Bot className="w-16 h-16 text-primary animate-pulse" />
<h2 className="text-2xl font-semibold animate-pulse">Error</h2>
<AlertBlock type="error">
{error?.message || "Error generating suggestions"}
</AlertBlock>
</div>
);
}
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center h-full space-y-4">
<Bot className="w-16 h-16 text-primary animate-pulse" />
<h2 className="text-2xl font-semibold animate-pulse">
AI is processing your request
</h2>
<p className="text-muted-foreground">
Generating template suggestions based on your input...
</p>
<pre>{templateInfo.userInput}</pre>
</div>
);
}
return (
<div className="flex flex-col h-full gap-6">
<div className="flex-grow overflow-auto pb-8">
<div className="space-y-6">
<h2 className="text-lg font-semibold">Step 2: Choose a Variant</h2>
{!selectedVariant && (
<div className="space-y-4">
<div>Based on your input, we suggest the following variants:</div>
<RadioGroup
// value={selectedVariant?.}
onValueChange={(value) => {
const element = suggestions?.find((s) => s?.id === value);
setTemplateInfo({
...templateInfo,
details: element,
});
}}
className="space-y-4"
>
{suggestions?.map((suggestion) => (
<div
key={suggestion?.id}
className="flex items-start space-x-3"
>
<RadioGroupItem
value={suggestion?.id || ""}
id={suggestion?.id}
className="mt-1"
/>
<div>
<Label htmlFor={suggestion?.id} className="font-medium">
{suggestion?.name}
</Label>
<p className="text-sm text-muted-foreground">
{suggestion?.shortDescription}
</p>
</div>
</div>
))}
</RadioGroup>
</div>
)}
{selectedVariant && (
<>
<div className="mb-6">
<h3 className="text-xl font-bold">{selectedVariant?.name}</h3>
<p className="text-muted-foreground mt-2">
{selectedVariant?.shortDescription}
</p>
</div>
<ScrollArea>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="description">
<AccordionTrigger>Description</AccordionTrigger>
<AccordionContent>
<ScrollArea className=" w-full rounded-md border p-4">
<ReactMarkdown className="text-muted-foreground text-sm">
{selectedVariant?.description}
</ReactMarkdown>
</ScrollArea>
</AccordionContent>
</AccordionItem>
<AccordionItem value="docker-compose">
<AccordionTrigger>Docker Compose</AccordionTrigger>
<AccordionContent>
<CodeEditor
value={selectedVariant?.dockerCompose}
className="font-mono"
onChange={(value) => {
setTemplateInfo({
...templateInfo,
...(templateInfo?.details && {
details: {
...templateInfo.details,
dockerCompose: value,
},
}),
});
}}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="env-variables">
<AccordionTrigger>Environment Variables</AccordionTrigger>
<AccordionContent>
<ScrollArea className=" w-full rounded-md border">
<div className="p-4 space-y-4">
{selectedVariant?.envVariables.map((env, index) => (
<div
key={index}
className="flex items-center space-x-2"
>
<Input
value={env.name}
onChange={(e) =>
handleEnvVariableChange(
index,
"name",
e.target.value,
)
}
placeholder="Variable Name"
className="flex-1"
/>
<div className="flex-1 relative">
<Input
type={
showValues[env.name] ? "text" : "password"
}
value={env.value}
onChange={(e) =>
handleEnvVariableChange(
index,
"value",
e.target.value,
)
}
placeholder="Variable Value"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 transform -translate-y-1/2"
onClick={() => toggleShowValue(env.name)}
>
{showValues[env.name] ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeEnvVariable(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="mt-2"
onClick={addEnvVariable}
>
<PlusCircle className="h-4 w-4 mr-2" />
Add Variable
</Button>
</div>
</ScrollArea>
</AccordionContent>
</AccordionItem>
<AccordionItem value="domains">
<AccordionTrigger>Domains</AccordionTrigger>
<AccordionContent>
<ScrollArea className=" w-full rounded-md border">
<div className="p-4 space-y-4">
{selectedVariant?.domains.map((domain, index) => (
<div
key={index}
className="flex items-center space-x-2"
>
<Input
value={domain.host}
onChange={(e) =>
handleDomainChange(
index,
"host",
e.target.value,
)
}
placeholder="Domain Host"
className="flex-1"
/>
<Input
type="number"
value={domain.port}
onChange={(e) =>
handleDomainChange(
index,
"port",
Number.parseInt(e.target.value),
)
}
placeholder="Port"
className="w-24"
/>
<Input
value={domain.serviceName}
onChange={(e) =>
handleDomainChange(
index,
"serviceName",
e.target.value,
)
}
placeholder="Service Name"
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeDomain(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="mt-2"
onClick={addDomain}
>
<PlusCircle className="h-4 w-4 mr-2" />
Add Domain
</Button>
</div>
</ScrollArea>
</AccordionContent>
</AccordionItem>
<AccordionItem value="mounts">
<AccordionTrigger>Configuration Files</AccordionTrigger>
<AccordionContent>
<ScrollArea className="w-full rounded-md border">
<div className="p-4 space-y-4">
{selectedVariant?.configFiles?.length > 0 ? (
<>
<div className="text-sm text-muted-foreground mb-4">
This template requires the following
configuration files to be mounted:
</div>
{selectedVariant.configFiles.map(
(config, index) => (
<div
key={index}
className="space-y-2 border rounded-lg p-4"
>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-primary">
{config.filePath}
</Label>
<p className="text-xs text-muted-foreground">
Will be mounted as: ../files
{config.filePath}
</p>
</div>
</div>
<CodeEditor
value={config.content}
className="font-mono"
onChange={(value) => {
if (!selectedVariant?.configFiles)
return;
const updatedConfigFiles = [
...selectedVariant.configFiles,
];
updatedConfigFiles[index] = {
filePath: config.filePath,
content: value,
};
setTemplateInfo({
...templateInfo,
...(templateInfo.details && {
details: {
...templateInfo.details,
configFiles: updatedConfigFiles,
},
}),
});
}}
/>
</div>
),
)}
</>
) : (
<div className="text-center text-muted-foreground py-8">
<p>
This template doesn't require any configuration
files.
</p>
<p className="text-sm mt-2">
All necessary configurations are handled through
environment variables.
</p>
</div>
)}
</div>
</ScrollArea>
</AccordionContent>
</AccordionItem>
</Accordion>
</ScrollArea>
</>
)}
</div>
</div>
<div className="">
<div className="flex justify-between">
{selectedVariant && (
<Button
onClick={() => {
const { details, ...rest } = templateInfo;
setTemplateInfo(rest);
}}
variant="outline"
>
Change Variant
</Button>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,338 @@
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { api } from "@/utils/api";
import { defineStepper } from "@stepperize/react";
import { Bot } from "lucide-react";
import Link from "next/link";
import React, { useState } from "react";
import { toast } from "sonner";
import { StepOne } from "./step-one";
import { StepThree } from "./step-three";
import { StepTwo } from "./step-two";
interface EnvVariable {
name: string;
value: string;
}
interface Domain {
host: string;
port: number;
serviceName: string;
}
interface Details {
name: string;
id: string;
description: string;
dockerCompose: string;
envVariables: EnvVariable[];
shortDescription: string;
domains: Domain[];
configFiles: Mount[];
}
interface Mount {
filePath: string;
content: string;
}
export interface TemplateInfo {
userInput: string;
details?: Details | null;
suggestions?: Details[];
server?: {
serverId: string;
name: string;
};
aiId: string;
}
const defaultTemplateInfo: TemplateInfo = {
aiId: "",
userInput: "",
server: undefined,
details: null,
suggestions: [],
};
export const { useStepper, steps, Scoped } = defineStepper(
{
id: "needs",
title: "Describe your needs",
},
{
id: "variant",
title: "Choose a Variant",
},
{
id: "review",
title: "Review and Finalize",
},
);
interface Props {
projectId: string;
projectName?: string;
}
export const TemplateGenerator = ({ projectId }: Props) => {
const [open, setOpen] = useState(false);
const stepper = useStepper();
const { data: aiSettings } = api.ai.getAll.useQuery();
const { mutateAsync } = api.ai.deploy.useMutation();
const [templateInfo, setTemplateInfo] =
useState<TemplateInfo>(defaultTemplateInfo);
const utils = api.useUtils();
const haveAtleasOneProviderEnabled = aiSettings?.some(
(ai) => ai.isEnabled === true,
);
const isDisabled = () => {
if (stepper.current.id === "needs") {
return !templateInfo.aiId || !templateInfo.userInput;
}
if (stepper.current.id === "variant") {
return !templateInfo?.details?.id;
}
return false;
};
const onSubmit = async () => {
await mutateAsync({
projectId,
id: templateInfo.details?.id || "",
name: templateInfo?.details?.name || "",
description: templateInfo?.details?.shortDescription || "",
dockerCompose: templateInfo?.details?.dockerCompose || "",
envVariables: (templateInfo?.details?.envVariables || [])
.map((env: any) => `${env.name}=${env.value}`)
.join("\n"),
domains: templateInfo?.details?.domains || [],
...(templateInfo.server?.serverId && {
serverId: templateInfo.server?.serverId || "",
}),
configFiles: templateInfo?.details?.configFiles || [],
})
.then(async () => {
toast.success("Compose Created");
setOpen(false);
await utils.project.one.invalidate({
projectId,
});
})
.catch(() => {
toast.error("Error creating the compose");
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild className="w-full">
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
<Bot className="size-4 text-muted-foreground" />
<span>AI Assistant</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-4xl w-full flex flex-col">
<DialogHeader>
<DialogTitle>AI Assistant</DialogTitle>
<DialogDescription>
Create a custom template based on your needs
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="flex justify-between">
<h2 className="text-lg font-semibold">Steps</h2>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Step {stepper.current.index + 1} of {steps.length}
</span>
<div />
</div>
</div>
<Scoped>
<nav aria-label="Checkout Steps" className="group my-4">
<ol
className="flex items-center justify-between gap-2"
aria-orientation="horizontal"
>
{stepper.all.map((step, index, array) => (
<React.Fragment key={step.id}>
<li className="flex items-center gap-4 flex-shrink-0">
<Button
type="button"
role="tab"
variant={
index <= stepper.current.index ? "secondary" : "ghost"
}
aria-current={
stepper.current.id === step.id ? "step" : undefined
}
aria-posinset={index + 1}
aria-setsize={steps.length}
aria-selected={stepper.current.id === step.id}
className="flex size-10 items-center justify-center rounded-full border-2 border-border"
>
{index + 1}
</Button>
<span className="text-sm font-medium">{step.title}</span>
</li>
{index < array.length - 1 && (
<Separator
className={`flex-1 ${
index < stepper.current.index
? "bg-primary"
: "bg-muted"
}`}
/>
)}
</React.Fragment>
))}
</ol>
</nav>
{stepper.switch({
needs: () => (
<>
{!haveAtleasOneProviderEnabled && (
<AlertBlock type="warning">
<div className="flex flex-col w-full">
<span>AI features are not enabled</span>
<span>
To use AI-powered template generation, please{" "}
<Link
href="/dashboard/settings/ai"
className="font-medium underline underline-offset-4"
>
enable AI in your settings
</Link>
.
</span>
</div>
</AlertBlock>
)}
{haveAtleasOneProviderEnabled &&
aiSettings &&
aiSettings?.length > 0 && (
<div className="space-y-4">
<div className="flex flex-col gap-2">
<label
htmlFor="user-needs"
className="text-sm font-medium"
>
Select AI Provider
</label>
<Select
value={templateInfo.aiId}
onValueChange={(value) =>
setTemplateInfo((prev) => ({
...prev,
aiId: value,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Select an AI provider" />
</SelectTrigger>
<SelectContent>
{aiSettings.map((ai) => (
<SelectItem key={ai.aiId} value={ai.aiId}>
{ai.name} ({ai.model})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{templateInfo.aiId && (
<StepOne
setTemplateInfo={setTemplateInfo}
templateInfo={templateInfo}
/>
)}
</div>
)}
</>
),
variant: () => (
<StepTwo
templateInfo={templateInfo}
setTemplateInfo={setTemplateInfo}
/>
),
review: () => (
<StepThree
templateInfo={templateInfo}
setTemplateInfo={setTemplateInfo}
/>
),
})}
</Scoped>
</div>
<DialogFooter>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 w-full justify-end">
<Button
onClick={stepper.prev}
disabled={stepper.isFirst}
variant="secondary"
>
Back
</Button>
<Button
disabled={isDisabled()}
onClick={async () => {
if (stepper.current.id === "needs") {
setTemplateInfo((prev) => ({
...prev,
suggestions: [],
details: null,
}));
}
if (stepper.isLast) {
await onSubmit();
return;
}
stepper.next();
// if (stepper.isLast) {
// // setIsOpen(false);
// // push("/dashboard/projects");
// } else {
// stepper.next();
// }
}}
>
{stepper.isLast ? "Create" : "Next"}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -97,6 +97,18 @@ export const HandleProject = ({ projectId }: Props) => {
); );
}); });
}; };
// useEffect(() => {
// const getUsers = async () => {
// const users = await authClient.admin.listUsers({
// query: {
// limit: 100,
// },
// });
// console.log(users);
// };
// getUsers();
// });
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>

View File

@@ -51,15 +51,7 @@ import { ProjectEnvironment } from "./project-environment";
export const ShowProjects = () => { export const ShowProjects = () => {
const utils = api.useUtils(); const utils = api.useUtils();
const { data, isLoading } = api.project.all.useQuery(); const { data, isLoading } = api.project.all.useQuery();
const { data: auth } = api.auth.get.useQuery(); const { data: auth } = api.user.get.useQuery();
const { data: user } = api.user.byAuthId.useQuery(
{
authId: auth?.id || "",
},
{
enabled: !!auth?.id && auth?.rol === "user",
},
);
const { mutateAsync } = api.project.remove.useMutation(); const { mutateAsync } = api.project.remove.useMutation();
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@@ -91,7 +83,7 @@ export const ShowProjects = () => {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
{(auth?.rol === "admin" || user?.canCreateProjects) && ( {(auth?.role === "owner" || auth?.canCreateProjects) && (
<div className=""> <div className="">
<HandleProject /> <HandleProject />
</div> </div>
@@ -293,8 +285,8 @@ export const ShowProjects = () => {
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{(auth?.rol === "admin" || {(auth?.role === "owner" ||
user?.canDeleteProjects) && ( auth?.canDeleteProjects) && (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger className="w-full"> <AlertDialogTrigger className="w-full">
<DropdownMenuItem <DropdownMenuItem

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