Merge branch 'canary' into ulimits-at-0a401843

This commit is contained in:
Mauricio Siu
2026-02-08 23:30:14 -06:00
234 changed files with 69952 additions and 5923 deletions

View File

@@ -0,0 +1,154 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
export const endpointSpecFormSchema = z.object({
Mode: z.string().optional(),
});
interface EndpointSpecFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(endpointSpecFormSchema),
defaultValues: {
Mode: undefined,
},
});
useEffect(() => {
if (data?.endpointSpecSwarm) {
const es = data.endpointSpecSwarm;
form.reset({
Mode: es.Mode,
});
}
}, [data, form]);
const onSubmit = async (formData: z.infer<typeof endpointSpecFormSchema>) => {
setIsLoading(true);
try {
// Check if all values are empty, if so, send null to clear the database
const hasAnyValue =
formData.Mode !== undefined &&
formData.Mode !== null &&
formData.Mode !== "";
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
endpointSpecSwarm: hasAnyValue ? formData : null,
});
toast.success("Endpoint spec updated successfully");
refetch();
} catch {
toast.error("Error updating endpoint spec");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="Mode"
render={({ field }) => (
<FormItem>
<FormLabel>Mode</FormLabel>
<FormDescription>Endpoint mode (vip or dnsrr)</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select endpoint mode" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="vip">VIP (Virtual IP)</SelectItem>
<SelectItem value="dnsrr">DNS Round Robin</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
Mode: undefined,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Endpoint Spec
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,270 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
export const healthCheckFormSchema = z.object({
Test: z.array(z.string()).optional(),
Interval: z.coerce.number().optional(),
Timeout: z.coerce.number().optional(),
StartPeriod: z.coerce.number().optional(),
Retries: z.coerce.number().optional(),
});
interface HealthCheckFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(healthCheckFormSchema),
defaultValues: {
Test: [],
Interval: undefined,
Timeout: undefined,
StartPeriod: undefined,
Retries: undefined,
},
});
const testCommands = form.watch("Test") || [];
useEffect(() => {
if (data?.healthCheckSwarm) {
const hc = data.healthCheckSwarm;
form.reset({
Test: hc.Test || [],
Interval: hc.Interval,
Timeout: hc.Timeout,
StartPeriod: hc.StartPeriod,
Retries: hc.Retries,
});
}
}, [data, form]);
const onSubmit = async (formData: z.infer<typeof healthCheckFormSchema>) => {
setIsLoading(true);
try {
// Check if all values are empty, if so, send null to clear the database
const hasAnyValue =
(formData.Test && formData.Test.length > 0) ||
formData.Interval !== undefined ||
formData.Timeout !== undefined ||
formData.StartPeriod !== undefined ||
formData.Retries !== undefined;
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
healthCheckSwarm: hasAnyValue ? formData : null,
});
toast.success("Health check updated successfully");
refetch();
} catch {
toast.error("Error updating health check");
} finally {
setIsLoading(false);
}
};
const addTestCommand = () => {
form.setValue("Test", [...testCommands, ""]);
};
const updateTestCommand = (index: number, value: string) => {
const newCommands = [...testCommands];
newCommands[index] = value;
form.setValue("Test", newCommands);
};
const removeTestCommand = (index: number) => {
form.setValue(
"Test",
testCommands.filter((_: string, i: number) => i !== index),
);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div>
<FormLabel>Test Commands</FormLabel>
<FormDescription>
Command to run for health check (e.g., ["CMD-SHELL", "curl -f
http://localhost:3000/health"])
</FormDescription>
<div className="space-y-2 mt-2">
{testCommands.map((cmd: string, index: number) => (
<div key={index} className="flex gap-2">
<Input
value={cmd}
onChange={(e) => updateTestCommand(index, e.target.value)}
placeholder={
index === 0
? "CMD-SHELL"
: "curl -f http://localhost:3000/health"
}
/>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => removeTestCommand(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addTestCommand}
>
Add Command
</Button>
</div>
</div>
<FormField
control={form.control}
name="Interval"
render={({ field }) => (
<FormItem>
<FormLabel>Interval (nanoseconds)</FormLabel>
<FormDescription>
Time between health checks (e.g., 10000000000 for 10 seconds)
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Timeout"
render={({ field }) => (
<FormItem>
<FormLabel>Timeout (nanoseconds)</FormLabel>
<FormDescription>
Maximum time to wait for health check response
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="StartPeriod"
render={({ field }) => (
<FormItem>
<FormLabel>Start Period (nanoseconds)</FormLabel>
<FormDescription>
Initial grace period before health checks begin
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Retries"
render={({ field }) => (
<FormItem>
<FormLabel>Retries</FormLabel>
<FormDescription>
Number of consecutive failures needed to consider container
unhealthy
</FormDescription>
<FormControl>
<Input type="number" placeholder="3" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
Test: [],
Interval: undefined,
Timeout: undefined,
StartPeriod: undefined,
Retries: undefined,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Health Check
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,11 @@
export { EndpointSpecForm } from "./endpoint-spec-form";
export { HealthCheckForm } from "./health-check-form";
export { LabelsForm } from "./labels-form";
export { ModeForm } from "./mode-form";
export { NetworkForm } from "./network-form";
export { PlacementForm } from "./placement-form";
export { RestartPolicyForm } from "./restart-policy-form";
export { RollbackConfigForm } from "./rollback-config-form";
export { StopGracePeriodForm } from "./stop-grace-period-form";
export { UpdateConfigForm } from "./update-config-form";
export { filterEmptyValues, hasValues } from "./utils";

View File

@@ -0,0 +1,200 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
export const labelsFormSchema = z.object({
labels: z
.array(
z.object({
key: z.string(),
value: z.string(),
}),
)
.optional(),
});
interface LabelsFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const LabelsForm = ({ id, type }: LabelsFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(labelsFormSchema),
defaultValues: {
labels: [],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "labels",
});
useEffect(() => {
if (data?.labelsSwarm && typeof data.labelsSwarm === "object") {
const labelEntries = Object.entries(data.labelsSwarm).map(
([key, value]) => ({
key,
value: value as string,
}),
);
form.reset({ labels: labelEntries });
}
}, [data, form]);
const onSubmit = async (formData: z.infer<typeof labelsFormSchema>) => {
setIsLoading(true);
try {
const labelsObject =
formData.labels?.reduce(
(acc, { key, value }) => {
if (key && value) {
acc[key] = value;
}
return acc;
},
{} as Record<string, string>,
) || {};
// If no labels, send null to clear the database
const labelsToSend =
Object.keys(labelsObject).length > 0 ? labelsObject : null;
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
labelsSwarm: labelsToSend,
});
toast.success("Labels updated successfully");
refetch();
} catch {
toast.error("Error updating labels");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div>
<FormLabel>Labels</FormLabel>
<FormDescription>
Add key-value labels to your service
</FormDescription>
<div className="space-y-2 mt-2">
{fields.map((field, index) => (
<div key={field.id} className="flex gap-2">
<FormField
control={form.control}
name={`labels.${index}.key`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input {...field} placeholder="com.example.app.name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`labels.${index}.value`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input {...field} placeholder="my-app" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => remove(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ key: "", value: "" })}
>
Add Label
</Button>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({ labels: [] });
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Labels
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,195 @@
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
interface ModeFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const ModeForm = ({ id, type }: ModeFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
defaultValues: {
type: undefined,
Replicas: undefined,
},
});
const modeType = form.watch("type");
useEffect(() => {
if (data?.modeSwarm) {
const mode = data.modeSwarm;
if (mode.Replicated) {
form.reset({
type: "Replicated",
Replicas: mode.Replicated.Replicas,
});
} else if (mode.Global) {
form.reset({
type: "Global",
Replicas: undefined,
});
}
}
}, [data, form]);
const onSubmit = async (formData: any) => {
setIsLoading(true);
try {
// If no type is selected, send null to clear the database
if (!formData.type) {
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
modeSwarm: null,
});
toast.success("Mode updated successfully");
refetch();
setIsLoading(false);
return;
}
const modeData =
formData.type === "Replicated"
? { Replicated: { Replicas: formData.Replicas } }
: { Global: {} };
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
modeSwarm: modeData,
});
toast.success("Mode updated successfully");
refetch();
} catch {
toast.error("Error updating mode");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Mode Type</FormLabel>
<FormDescription>
Choose between replicated or global service mode
</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select mode type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Replicated">Replicated</SelectItem>
<SelectItem value="Global">Global</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{modeType === "Replicated" && (
<FormField
control={form.control}
name="Replicas"
render={({ field }) => (
<FormItem>
<FormLabel>Replicas</FormLabel>
<FormDescription>Number of replicas to run</FormDescription>
<FormControl>
<Input type="number" placeholder="1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
type: undefined,
Replicas: undefined,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Mode
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,313 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const driverOptEntrySchema = z.object({
key: z.string(),
value: z.string(),
});
export const networkFormSchema = z.object({
networks: z
.array(
z.object({
Target: z.string().optional(),
Aliases: z.string().optional(),
DriverOptsEntries: z.array(driverOptEntrySchema).optional(),
}),
)
.optional(),
});
interface NetworkFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const NetworkForm = ({ id, type }: NetworkFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<z.infer<typeof networkFormSchema>>({
resolver: zodResolver(networkFormSchema),
defaultValues: {
networks: [],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "networks",
});
useEffect(() => {
if (data?.networkSwarm && Array.isArray(data.networkSwarm)) {
const networkEntries = data.networkSwarm.map((network) => ({
Target: network.Target || "",
Aliases: network.Aliases?.join(", ") || "",
DriverOptsEntries: network.DriverOpts
? Object.entries(network.DriverOpts).map(([key, value]) => ({
key,
value: value ?? "",
}))
: [],
}));
form.reset({ networks: networkEntries });
}
}, [data, form]);
const onSubmit = async (formData: z.infer<typeof networkFormSchema>) => {
setIsLoading(true);
try {
const networksArray =
formData.networks
?.filter((network) => network.Target)
.map((network) => {
const entries = (network.DriverOptsEntries ?? []).filter(
(e) => e.key.trim() !== "",
);
const driverOpts =
entries.length > 0
? Object.fromEntries(
entries.map((e) => [e.key.trim(), e.value]),
)
: undefined;
return {
Target: network.Target,
Aliases: network.Aliases
? network.Aliases.split(",").map((alias) => alias.trim())
: undefined,
DriverOpts: driverOpts,
};
}) || [];
// If no networks, send null to clear the database
const networksToSend = networksArray.length > 0 ? networksArray : null;
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
networkSwarm: networksToSend,
});
toast.success("Network configuration updated successfully");
refetch();
} catch {
toast.error("Error updating network configuration");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div>
<FormLabel>Networks</FormLabel>
<FormDescription>
Configure network attachments for your service
</FormDescription>
<div className="space-y-2 mt-2">
{fields.map((field, index) => (
<div key={field.id} className="space-y-2 p-3 border rounded">
<FormField
control={form.control}
name={`networks.${index}.Target`}
render={({ field }) => (
<FormItem>
<FormLabel>Network Name</FormLabel>
<FormControl>
<Input {...field} placeholder="my-network" />
</FormControl>
<FormDescription>
The name of the network to attach to
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`networks.${index}.Aliases`}
render={({ field }) => (
<FormItem>
<FormLabel>Aliases (optional)</FormLabel>
<FormControl>
<Input
{...field}
placeholder="alias1, alias2, alias3"
/>
</FormControl>
<FormDescription>
Comma-separated list of network aliases
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<FormLabel>Driver options (optional)</FormLabel>
<FormDescription>
e.g. com.docker.network.driver.mtu,
com.docker.network.driver.host_binding
</FormDescription>
{(
form.watch(`networks.${index}.DriverOptsEntries`) ?? []
).map((_, optIndex) => (
<div
key={optIndex}
className="flex gap-2 items-end flex-wrap"
>
<FormField
control={form.control}
name={`networks.${index}.DriverOptsEntries.${optIndex}.key`}
render={({ field }) => (
<FormItem className="flex-1 min-w-[140px]">
<FormControl>
<Input
{...field}
placeholder="com.docker.network.driver.mtu"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`networks.${index}.DriverOptsEntries.${optIndex}.value`}
render={({ field }) => (
<FormItem className="flex-1 min-w-[100px]">
<FormControl>
<Input {...field} placeholder="1500" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const entries =
form.getValues(
`networks.${index}.DriverOptsEntries`,
) ?? [];
form.setValue(
`networks.${index}.DriverOptsEntries`,
entries.filter((_, i) => i !== optIndex),
);
}}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const entries =
form.getValues(`networks.${index}.DriverOptsEntries`) ??
[];
form.setValue(`networks.${index}.DriverOptsEntries`, [
...entries,
{ key: "", value: "" },
]);
}}
>
Add driver option
</Button>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => remove(index)}
>
Remove Network
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
append({
Target: "",
Aliases: "",
DriverOptsEntries: [],
})
}
>
Add Network
</Button>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({ networks: [] });
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Networks
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,347 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const PreferenceSchema = z.object({
SpreadDescriptor: z.string(),
});
const PlatformSchema = z.object({
Architecture: z.string(),
OS: z.string(),
});
export const placementFormSchema = z.object({
Constraints: z.array(z.string()).optional(),
Preferences: z.array(PreferenceSchema).optional(),
MaxReplicas: z.coerce.number().optional(),
Platforms: z.array(PlatformSchema).optional(),
});
interface PlacementFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const PlacementForm = ({ id, type }: PlacementFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(placementFormSchema),
defaultValues: {
Constraints: [],
Preferences: [],
MaxReplicas: undefined,
Platforms: [],
},
});
const constraints = form.watch("Constraints") || [];
const preferences = form.watch("Preferences") || [];
const platforms = form.watch("Platforms") || [];
useEffect(() => {
if (data?.placementSwarm) {
const placement = data.placementSwarm;
form.reset({
Constraints: placement.Constraints || [],
Preferences:
placement.Preferences?.map((p: any) => ({
SpreadDescriptor: p.Spread?.SpreadDescriptor || "",
})) || [],
MaxReplicas: placement.MaxReplicas,
Platforms: placement.Platforms || [],
});
}
}, [data, form]);
const onSubmit = async (formData: z.infer<typeof placementFormSchema>) => {
setIsLoading(true);
try {
// Check if all values are empty, if so, send null to clear the database
const hasAnyValue =
(formData.Constraints && formData.Constraints.length > 0) ||
(formData.Preferences && formData.Preferences.length > 0) ||
(formData.Platforms && formData.Platforms.length > 0) ||
formData.MaxReplicas !== undefined;
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
placementSwarm: hasAnyValue
? {
...formData,
Preferences: formData.Preferences?.map((p) => ({
Spread: { SpreadDescriptor: p.SpreadDescriptor },
})),
}
: null,
});
toast.success("Placement updated successfully");
refetch();
} catch {
toast.error("Error updating placement");
} finally {
setIsLoading(false);
}
};
const addConstraint = () => {
form.setValue("Constraints", [...constraints, ""]);
};
const updateConstraint = (index: number, value: string) => {
const newConstraints = [...constraints];
newConstraints[index] = value;
form.setValue("Constraints", newConstraints);
};
const removeConstraint = (index: number) => {
form.setValue(
"Constraints",
constraints.filter((_: string, i: number) => i !== index),
);
};
const addPreference = () => {
form.setValue("Preferences", [...preferences, { SpreadDescriptor: "" }]);
};
const updatePreference = (index: number, value: string) => {
const newPreferences = [...preferences];
if (newPreferences[index]) {
newPreferences[index].SpreadDescriptor = value;
form.setValue("Preferences", newPreferences);
}
};
const removePreference = (index: number) => {
form.setValue(
"Preferences",
preferences.filter((_: any, i: number) => i !== index),
);
};
const addPlatform = () => {
form.setValue("Platforms", [...platforms, { Architecture: "", OS: "" }]);
};
const updatePlatform = (
index: number,
field: "Architecture" | "OS",
value: string,
) => {
const newPlatforms = [...platforms];
if (newPlatforms[index]) {
newPlatforms[index][field] = value;
form.setValue("Platforms", newPlatforms);
}
};
const removePlatform = (index: number) => {
form.setValue(
"Platforms",
platforms.filter((_: any, i: number) => i !== index),
);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div>
<FormLabel>Constraints</FormLabel>
<FormDescription>
Placement constraints (e.g., "node.role==manager")
</FormDescription>
<div className="space-y-2 mt-2">
{constraints.map((constraint: string, index: number) => (
<div key={index} className="flex gap-2">
<Input
value={constraint}
onChange={(e) => updateConstraint(index, e.target.value)}
placeholder="node.role==manager"
/>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => removeConstraint(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addConstraint}
>
Add Constraint
</Button>
</div>
</div>
<div>
<FormLabel>Preferences</FormLabel>
<FormDescription>
Spread preferences for task distribution (e.g.,
"node.labels.region")
</FormDescription>
<div className="space-y-2 mt-2">
{preferences.map((pref: any, index: number) => (
<div key={index} className="flex gap-2">
<Input
value={pref.SpreadDescriptor}
onChange={(e) => updatePreference(index, e.target.value)}
placeholder="node.labels.region"
/>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => removePreference(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addPreference}
>
Add Preference
</Button>
</div>
</div>
<FormField
control={form.control}
name="MaxReplicas"
render={({ field }) => (
<FormItem>
<FormLabel>Max Replicas</FormLabel>
<FormDescription>
Maximum number of replicas per node
</FormDescription>
<FormControl>
<Input type="number" placeholder="10" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel>Platforms</FormLabel>
<FormDescription>
Target platforms for task scheduling
</FormDescription>
<div className="space-y-2 mt-2">
{platforms.map((platform: any, index: number) => (
<div key={index} className="flex gap-2">
<Input
value={platform.Architecture}
onChange={(e) =>
updatePlatform(index, "Architecture", e.target.value)
}
placeholder="amd64"
/>
<Input
value={platform.OS}
onChange={(e) => updatePlatform(index, "OS", e.target.value)}
placeholder="linux"
/>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => removePlatform(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addPlatform}
>
Add Platform
</Button>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
Constraints: [],
Preferences: [],
MaxReplicas: undefined,
Platforms: [],
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Placement
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,219 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
export const restartPolicyFormSchema = z.object({
Condition: z.string().optional(),
Delay: z.coerce.number().optional(),
MaxAttempts: z.coerce.number().optional(),
Window: z.coerce.number().optional(),
});
interface RestartPolicyFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(restartPolicyFormSchema),
defaultValues: {
Condition: undefined,
Delay: undefined,
MaxAttempts: undefined,
Window: undefined,
},
});
useEffect(() => {
if (data?.restartPolicySwarm) {
form.reset({
Condition: data.restartPolicySwarm.Condition,
Delay: data.restartPolicySwarm.Delay,
MaxAttempts: data.restartPolicySwarm.MaxAttempts,
Window: data.restartPolicySwarm.Window,
});
}
}, [data, form]);
const onSubmit = async (
formData: z.infer<typeof restartPolicyFormSchema>,
) => {
setIsLoading(true);
try {
// Check if all values are empty, if so, send null to clear the database
const hasAnyValue = Object.values(formData).some(
(value) => value !== undefined && value !== null && value !== "",
);
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
restartPolicySwarm: hasAnyValue ? formData : null,
});
toast.success("Restart policy updated successfully");
refetch();
} catch {
toast.error("Error updating restart policy");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="Condition"
render={({ field }) => (
<FormItem>
<FormLabel>Condition</FormLabel>
<FormDescription>When to restart the container</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select restart condition" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="on-failure">On Failure</SelectItem>
<SelectItem value="any">Any</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Delay"
render={({ field }) => (
<FormItem>
<FormLabel>Delay (nanoseconds)</FormLabel>
<FormDescription>
Wait time between restart attempts
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="MaxAttempts"
render={({ field }) => (
<FormItem>
<FormLabel>Max Attempts</FormLabel>
<FormDescription>
Maximum number of restart attempts
</FormDescription>
<FormControl>
<Input type="number" placeholder="3" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Window"
render={({ field }) => (
<FormItem>
<FormLabel>Window (nanoseconds)</FormLabel>
<FormDescription>
Time window to evaluate restart policy
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
Condition: undefined,
Delay: undefined,
MaxAttempts: undefined,
Window: undefined,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Restart Policy
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,257 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
export const rollbackConfigFormSchema = z.object({
Parallelism: z.coerce.number().optional(),
Delay: z.coerce.number().optional(),
FailureAction: z.string().optional(),
Monitor: z.coerce.number().optional(),
MaxFailureRatio: z.coerce.number().optional(),
Order: z.string().optional(),
});
interface RollbackConfigFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(rollbackConfigFormSchema),
defaultValues: {
Parallelism: undefined,
Delay: undefined,
FailureAction: undefined,
Monitor: undefined,
MaxFailureRatio: undefined,
Order: undefined,
},
});
useEffect(() => {
if (data?.rollbackConfigSwarm) {
form.reset(data.rollbackConfigSwarm);
}
}, [data, form]);
const onSubmit = async (
formData: z.infer<typeof rollbackConfigFormSchema>,
) => {
setIsLoading(true);
try {
// Check if all values are empty, if so, send null to clear the database
const hasAnyValue = Object.values(formData).some(
(value) => value !== undefined && value !== null && value !== "",
);
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
rollbackConfigSwarm: (hasAnyValue ? formData : null) as any,
});
toast.success("Rollback config updated successfully");
refetch();
} catch {
toast.error("Error updating rollback config");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="Parallelism"
render={({ field }) => (
<FormItem>
<FormLabel>Parallelism</FormLabel>
<FormDescription>
Number of tasks to rollback simultaneously
</FormDescription>
<FormControl>
<Input type="number" placeholder="1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Delay"
render={({ field }) => (
<FormItem>
<FormLabel>Delay (nanoseconds)</FormLabel>
<FormDescription>Delay between task rollbacks</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="FailureAction"
render={({ field }) => (
<FormItem>
<FormLabel>Failure Action</FormLabel>
<FormDescription>Action on rollback failure</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select failure action" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="pause">Pause</SelectItem>
<SelectItem value="continue">Continue</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Monitor"
render={({ field }) => (
<FormItem>
<FormLabel>Monitor (nanoseconds)</FormLabel>
<FormDescription>
Duration to monitor for failure after rollback
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="MaxFailureRatio"
render={({ field }) => (
<FormItem>
<FormLabel>Max Failure Ratio</FormLabel>
<FormDescription>
Maximum failure ratio tolerated (0-1)
</FormDescription>
<FormControl>
<Input type="number" step="0.01" placeholder="0.1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Order"
render={({ field }) => (
<FormItem>
<FormLabel>Order</FormLabel>
<FormDescription>Rollback order strategy</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select order" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="stop-first">Stop First</SelectItem>
<SelectItem value="start-first">Start First</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
Parallelism: undefined,
Delay: undefined,
FailureAction: undefined,
Monitor: undefined,
MaxFailureRatio: undefined,
Order: undefined,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Rollback Config
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,158 @@
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const hasStopGracePeriodSwarm = (
value: unknown,
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
typeof value === "object" &&
value !== null &&
"stopGracePeriodSwarm" in value;
interface StopGracePeriodFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
defaultValues: {
value: null as bigint | null,
},
});
useEffect(() => {
if (hasStopGracePeriodSwarm(data)) {
const value = data.stopGracePeriodSwarm;
const normalizedValue =
value === null || value === undefined
? null
: typeof value === "bigint"
? value
: BigInt(value);
form.reset({
value: normalizedValue,
});
}
}, [data, form]);
const onSubmit = async (formData: any) => {
setIsLoading(true);
try {
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
stopGracePeriodSwarm: formData.value,
});
toast.success("Stop grace period updated successfully");
refetch();
} catch {
toast.error("Error updating stop grace period");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="value"
render={({ field }) => (
<FormItem>
<FormLabel>Stop Grace Period (nanoseconds)</FormLabel>
<FormDescription>
Time to wait before forcefully killing the container
<br />
Examples: 30000000000 (30s), 120000000000 (2m)
</FormDescription>
<FormControl>
<Input
type="number"
placeholder="30000000000"
{...field}
value={
field?.value !== null && field?.value !== undefined
? field.value.toString()
: ""
}
onChange={(e) =>
field.onChange(
e.target.value ? BigInt(e.target.value) : null,
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
value: null,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Stop Grace Period
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,264 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
export const updateConfigFormSchema = z.object({
Parallelism: z.coerce.number().optional(),
Delay: z.coerce.number().optional(),
FailureAction: z.string().optional(),
Monitor: z.coerce.number().optional(),
MaxFailureRatio: z.coerce.number().optional(),
Order: z.string().optional(),
});
interface UpdateConfigFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(updateConfigFormSchema),
defaultValues: {
Parallelism: undefined,
Delay: undefined,
FailureAction: undefined,
Monitor: undefined,
MaxFailureRatio: undefined,
Order: undefined,
},
});
useEffect(() => {
if (data?.updateConfigSwarm) {
const config = data.updateConfigSwarm;
form.reset({
Parallelism: config.Parallelism,
Delay: config.Delay,
FailureAction: config.FailureAction,
Monitor: config.Monitor,
MaxFailureRatio: config.MaxFailureRatio,
Order: config.Order,
});
}
}, [data, form]);
const onSubmit = async (formData: z.infer<typeof updateConfigFormSchema>) => {
setIsLoading(true);
try {
// Check if all values are empty, if so, send null to clear the database
const hasAnyValue = Object.values(formData).some(
(value) => value !== undefined && value !== null && value !== "",
);
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
updateConfigSwarm: (hasAnyValue ? formData : null) as any,
});
toast.success("Update config updated successfully");
refetch();
} catch {
toast.error("Error updating update config");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="Parallelism"
render={({ field }) => (
<FormItem>
<FormLabel>Parallelism</FormLabel>
<FormDescription>
Number of tasks to update simultaneously
</FormDescription>
<FormControl>
<Input type="number" placeholder="1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Delay"
render={({ field }) => (
<FormItem>
<FormLabel>Delay (nanoseconds)</FormLabel>
<FormDescription>Delay between task updates</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="FailureAction"
render={({ field }) => (
<FormItem>
<FormLabel>Failure Action</FormLabel>
<FormDescription>Action on update failure</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select failure action" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="pause">Pause</SelectItem>
<SelectItem value="continue">Continue</SelectItem>
<SelectItem value="rollback">Rollback</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Monitor"
render={({ field }) => (
<FormItem>
<FormLabel>Monitor (nanoseconds)</FormLabel>
<FormDescription>
Duration to monitor for failure after update
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="MaxFailureRatio"
render={({ field }) => (
<FormItem>
<FormLabel>Max Failure Ratio</FormLabel>
<FormDescription>
Maximum failure ratio tolerated (0-1)
</FormDescription>
<FormControl>
<Input type="number" step="0.01" placeholder="0.1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Order"
render={({ field }) => (
<FormItem>
<FormLabel>Order</FormLabel>
<FormDescription>Update order strategy</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select order" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="stop-first">Stop First</SelectItem>
<SelectItem value="start-first">Start First</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
Parallelism: undefined,
Delay: undefined,
FailureAction: undefined,
Monitor: undefined,
MaxFailureRatio: undefined,
Order: undefined,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Update Config
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,31 @@
/**
* Filters out undefined, null, and empty string values from form data
* Only returns fields that have actual values
*/
export const filterEmptyValues = (
formData: Record<string, any>,
): Record<string, any> => {
return Object.entries(formData).reduce(
(acc, [key, value]) => {
// Keep arrays even if empty (they might be intentionally cleared)
if (Array.isArray(value)) {
if (value.length > 0) {
acc[key] = value;
}
}
// For other values, filter out undefined, null, and empty strings
else if (value !== undefined && value !== null && value !== "") {
acc[key] = value;
}
return acc;
},
{} as Record<string, any>,
);
};
/**
* Checks if filtered data has any values to save
*/
export const hasValues = (data: Record<string, any>): boolean => {
return Object.keys(data).length > 0;
};

View File

@@ -24,6 +24,8 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
const UpdateTraefikConfigSchema = z.object({
@@ -59,6 +61,7 @@ export const validateAndFormatYAML = (yamlText: string) => {
export const UpdateTraefikConfig = ({ applicationId }: Props) => {
const [open, setOpen] = useState(false);
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
const { data, refetch } = api.application.readTraefikConfig.useQuery(
{
applicationId,
@@ -85,13 +88,15 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
}, [data]);
const onSubmit = async (data: UpdateTraefikConfig) => {
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
if (!valid) {
form.setError("traefikConfig", {
type: "manual",
message: (error as string) || "Invalid YAML",
});
return;
if (!skipYamlValidation) {
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
if (!valid) {
form.setError("traefikConfig", {
type: "manual",
message: (error as string) || "Invalid YAML",
});
return;
}
}
form.clearErrors("traefikConfig");
await mutateAsync({
@@ -116,6 +121,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
setOpen(open);
if (!open) {
form.reset();
setSkipYamlValidation(false);
}
}}
>
@@ -169,7 +175,28 @@ routers:
</div>
</form>
<DialogFooter>
<DialogFooter className="flex-col sm:flex-row gap-4">
<div className="flex flex-col gap-1 w-full sm:w-auto sm:mr-auto">
<div className="flex items-center space-x-2">
<Checkbox
id="skip-yaml-validation-app"
checked={skipYamlValidation}
onCheckedChange={(checked) =>
setSkipYamlValidation(checked === true)
}
/>
<Label
htmlFor="skip-yaml-validation-app"
className="text-sm font-normal cursor-pointer"
>
Skip YAML validation (for Go templating)
</Label>
</div>
<p className="text-sm text-muted-foreground">
Check to save configs with Go templating (e.g.{" "}
<code className="text-xs">{"{{range}}"}</code>).
</p>
</div>
<Button
isLoading={isLoading}
form="hook-form-update-traefik-config"

View File

@@ -1,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Cog } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -20,8 +20,39 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
// Railpack versions from https://github.com/railwayapp/railpack/releases
export const RAILPACK_VERSIONS = [
"0.15.4",
"0.15.3",
"0.15.2",
"0.15.1",
"0.15.0",
"0.14.0",
"0.13.0",
"0.12.0",
"0.11.0",
"0.10.0",
"0.9.2",
"0.9.1",
"0.9.0",
"0.8.0",
"0.7.0",
"0.6.0",
"0.5.0",
"0.4.0",
"0.3.0",
"0.2.2",
] as const;
export enum BuildType {
dockerfile = "dockerfile",
heroku_buildpacks = "heroku_buildpacks",
@@ -65,7 +96,7 @@ const mySchema = z.discriminatedUnion("buildType", [
}),
z.object({
buildType: z.literal(BuildType.railpack),
railpackVersion: z.string().nullable().default("0.2.2"),
railpackVersion: z.string().nullable().default("0.15.4"),
}),
z.object({
buildType: z.literal(BuildType.static),
@@ -152,6 +183,8 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
});
const buildType = form.watch("buildType");
const railpackVersion = form.watch("railpackVersion");
const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false);
useEffect(() => {
if (data) {
@@ -163,9 +196,22 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
};
form.reset(resetData(typedData));
// Check if railpack version is manual (not in the predefined list)
if (
data.railpackVersion &&
!RAILPACK_VERSIONS.includes(data.railpackVersion as any)
) {
setIsManualRailpackVersion(true);
}
}
}, [data, form]);
// Hide builder section when Docker provider is selected
if (data?.sourceType === "docker") {
return null;
}
const onSubmit = async (data: AddTemplate) => {
await mutateAsync({
applicationId,
@@ -186,7 +232,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
data.buildType === BuildType.static ? data.isStaticSpa : null,
railpackVersion:
data.buildType === BuildType.railpack
? data.railpackVersion || "0.2.2"
? data.railpackVersion || "0.15.4"
: null,
})
.then(async () => {
@@ -403,23 +449,88 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
/>
)}
{buildType === BuildType.railpack && (
<FormField
control={form.control}
name="railpackVersion"
render={({ field }) => (
<FormItem>
<FormLabel>Railpack Version</FormLabel>
<FormControl>
<Input
placeholder="Railpack Version"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<>
<FormField
control={form.control}
name="railpackVersion"
render={({ field }) => (
<FormItem>
<FormLabel>Railpack Version</FormLabel>
<FormControl>
{isManualRailpackVersion ? (
<div className="space-y-2">
<Input
placeholder="Enter custom version (e.g., 0.15.4)"
{...field}
value={field.value ?? ""}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setIsManualRailpackVersion(false);
field.onChange("0.15.4");
}}
>
Use predefined versions
</Button>
</div>
) : (
<Select
onValueChange={(value) => {
if (value === "manual") {
setIsManualRailpackVersion(true);
field.onChange("");
} else {
field.onChange(value);
}
}}
value={field.value ?? "0.15.4"}
>
<SelectTrigger>
<SelectValue placeholder="Select Railpack version" />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">
<span className="font-medium">
Manual (Custom Version)
</span>
</SelectItem>
{RAILPACK_VERSIONS.map((version) => (
<SelectItem key={version} value={version}>
v{version}
{version === "0.15.4" && (
<Badge
variant="secondary"
className="ml-2 px-1 text-xs"
>
Latest
</Badge>
)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</FormControl>
<FormDescription>
Select a Railpack version or choose manual to enter a
custom version.{" "}
<a
href="https://github.com/railwayapp/railpack/releases"
target="_blank"
rel="noreferrer"
className="text-primary underline underline-offset-4"
>
View releases
</a>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">

View File

@@ -256,9 +256,9 @@ export const ShowDeployments = ({
return (
<div
key={deployment.deploymentId}
className="flex items-center justify-between rounded-lg border p-4 gap-2"
className="flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex flex-col">
<div className="flex flex-1 flex-col min-w-0">
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
{index + 1}. {deployment.status}
<StatusTooltip
@@ -313,8 +313,8 @@ export const ShowDeployments = ({
)}
</div>
</div>
<div className="flex flex-col items-end gap-2 max-w-[300px] w-full justify-start">
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
<div className="flex w-full flex-col items-start gap-2 sm:w-auto sm:max-w-[300px] sm:items-end sm:justify-start">
<div className="text-sm capitalize text-muted-foreground flex flex-wrap items-center gap-2">
<DateTooltip date={deployment.createdAt} />
{deployment.startedAt && deployment.finishedAt && (
<Badge
@@ -333,7 +333,7 @@ export const ShowDeployments = ({
)}
</div>
<div className="flex flex-row items-center gap-2">
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center sm:justify-end">
{deployment.pid && deployment.status === "running" && (
<DialogAction
title="Kill Process"
@@ -355,6 +355,7 @@ export const ShowDeployments = ({
variant="destructive"
size="sm"
isLoading={isKillingProcess}
className="w-full sm:w-auto"
>
Kill Process
</Button>
@@ -364,6 +365,7 @@ export const ShowDeployments = ({
onClick={() => {
setActiveLog(deployment);
}}
className="w-full sm:w-auto"
>
View
</Button>
@@ -405,6 +407,7 @@ export const ShowDeployments = ({
variant="secondary"
size="sm"
isLoading={isRollingBack}
className="w-full sm:w-auto"
>
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
Rollback

View File

@@ -54,6 +54,7 @@ const BitbucketProviderSchema = z.object({
.object({
repo: z.string().min(1, "Repo is required"),
owner: z.string().min(1, "Owner is required"),
slug: z.string().optional(),
})
.required(),
branch: z.string().min(1, "Branch is required"),
@@ -82,6 +83,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
repository: {
owner: "",
repo: "",
slug: "",
},
bitbucketId: "",
branch: "",
@@ -114,11 +116,14 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
} = api.bitbucket.getBitbucketBranches.useQuery(
{
owner: repository?.owner,
repo: repository?.repo,
repo: repository?.slug || repository?.repo || "",
bitbucketId,
},
{
enabled: !!repository?.owner && !!repository?.repo && !!bitbucketId,
enabled:
!!repository?.owner &&
!!(repository?.slug || repository?.repo) &&
!!bitbucketId,
},
);
@@ -129,6 +134,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
repository: {
repo: data.bitbucketRepository || "",
owner: data.bitbucketOwner || "",
slug: data.bitbucketRepositorySlug || "",
},
buildPath: data.bitbucketBuildPath || "/",
bitbucketId: data.bitbucketId || "",
@@ -142,6 +148,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
await mutateAsync({
bitbucketBranch: data.branch,
bitbucketRepository: data.repository.repo,
bitbucketRepositorySlug: data.repository.slug || data.repository.repo,
bitbucketOwner: data.repository.owner,
bitbucketBuildPath: data.buildPath,
bitbucketId: data.bitbucketId,
@@ -181,6 +188,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
form.setValue("repository", {
owner: "",
repo: "",
slug: "",
});
form.setValue("branch", "");
}}
@@ -217,7 +225,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://bitbucket.org/${field.value.owner}/${field.value.repo}`}
href={`https://bitbucket.org/${field.value.owner}/${field.value.slug || field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
@@ -271,6 +279,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
form.setValue("repository", {
owner: repo.owner.username as string,
repo: repo.name,
slug: repo.slug,
});
form.setValue("branch", "");
}}

View File

@@ -232,9 +232,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<FormItem className="md:col-span-2 flex flex-col">
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
{field.value.gitlabPathNamespace && (
<Link
href={`${gitlabUrl}/${field.value.owner}/${field.value.repo}`}
href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"

View File

@@ -34,6 +34,7 @@ export const DockerLogs = dynamic(
export const badgeStateColor = (state: string) => {
switch (state) {
case "running":
case "ready":
return "green";
case "exited":
case "shutdown":
@@ -142,6 +143,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
{container.status ? ` ${container.status}` : ""}
</SelectItem>
))}
</div>
@@ -157,6 +159,9 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
{container.currentState
? ` ${container.currentState}`
: ""}
</SelectItem>
))}
</>
@@ -166,6 +171,13 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
</SelectGroup>
</SelectContent>
</Select>
{option === "swarm" &&
services?.find((c) => c.containerId === containerId)?.error && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-3 py-2 text-sm text-destructive">
<span className="font-medium">Error: </span>
{services?.find((c) => c.containerId === containerId)?.error}
</div>
)}
<DockerLogs
serverId={serverId || ""}
containerId={containerId || "select-a-container"}

View File

@@ -1,7 +1,9 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import {
ExternalLink,
FileText,
GitPullRequest,
Hammer,
Loader2,
PenSquare,
RocketIcon,
@@ -22,6 +24,12 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
@@ -38,6 +46,9 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
const { mutateAsync: deletePreviewDeployment, isLoading } =
api.previewDeployment.delete.useMutation();
const { mutateAsync: redeployPreviewDeployment } =
api.previewDeployment.redeploy.useMutation();
const {
data: previewDeployments,
refetch: refetchPreviewDeployments,
@@ -46,6 +57,8 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
{ applicationId },
{
enabled: !!applicationId,
refetchInterval: (data) =>
data?.some((d) => d.previewStatus === "running") ? 2000 : false,
},
);
@@ -193,6 +206,58 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
</Button>
</ShowDeploymentsModal>
<DialogAction
title="Rebuild Preview Deployment"
description="Are you sure you want to rebuild this preview deployment?"
type="default"
onClick={async () => {
await redeployPreviewDeployment({
previewDeploymentId:
deployment.previewDeploymentId,
})
.then(() => {
toast.success(
"Preview deployment rebuild started",
);
refetchPreviewDeployments();
})
.catch(() => {
toast.error(
"Error rebuilding preview deployment",
);
});
}}
>
<Button
variant="outline"
size="sm"
isLoading={status === "running"}
className="gap-2"
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2">
<Hammer className="size-4" />
Rebuild
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent
sideOffset={5}
className="z-[60]"
>
<p>
Rebuild the preview deployment without
downloading new code
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</TooltipProvider>
</Button>
</DialogAction>
<AddPreviewDomain
previewDeploymentId={`${deployment.previewDeploymentId}`}
domainId={deployment.domain?.domainId}

View File

@@ -54,6 +54,7 @@ const BitbucketProviderSchema = z.object({
.object({
repo: z.string().min(1, "Repo is required"),
owner: z.string().min(1, "Owner is required"),
slug: z.string().optional(),
})
.required(),
branch: z.string().min(1, "Branch is required"),
@@ -82,6 +83,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
repository: {
owner: "",
repo: "",
slug: "",
},
bitbucketId: "",
branch: "",
@@ -114,11 +116,14 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
} = api.bitbucket.getBitbucketBranches.useQuery(
{
owner: repository?.owner,
repo: repository?.repo,
repo: repository?.slug || repository?.repo || "",
bitbucketId,
},
{
enabled: !!repository?.owner && !!repository?.repo && !!bitbucketId,
enabled:
!!repository?.owner &&
!!(repository?.slug || repository?.repo) &&
!!bitbucketId,
},
);
@@ -129,6 +134,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
repository: {
repo: data.bitbucketRepository || "",
owner: data.bitbucketOwner || "",
slug: data.bitbucketRepositorySlug || "",
},
composePath: data.composePath,
bitbucketId: data.bitbucketId || "",
@@ -142,6 +148,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
await mutateAsync({
bitbucketBranch: data.branch,
bitbucketRepository: data.repository.repo,
bitbucketRepositorySlug: data.repository.slug || data.repository.repo,
bitbucketOwner: data.repository.owner,
bitbucketId: data.bitbucketId,
composePath: data.composePath,
@@ -183,6 +190,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
form.setValue("repository", {
owner: "",
repo: "",
slug: "",
});
form.setValue("branch", "");
}}
@@ -219,7 +227,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://bitbucket.org/${field.value.owner}/${field.value.repo}`}
href={`https://bitbucket.org/${field.value.owner}/${field.value.slug || field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
@@ -273,6 +281,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
form.setValue("repository", {
owner: repo.owner.username as string,
repo: repo.name,
slug: repo.slug,
});
form.setValue("branch", "");
}}

View File

@@ -1,7 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -97,6 +97,16 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
const repository = form.watch("repository");
const gitlabId = form.watch("gitlabId");
const gitlabUrl = useMemo(() => {
const url = gitlabProviders?.find(
(provider) => provider.gitlabId === gitlabId,
)?.gitlabUrl;
const gitlabUrl = url?.replace(/\/$/, "");
return gitlabUrl || "https://gitlab.com";
}, [gitlabId, gitlabProviders]);
const {
data: repositories,
isLoading: isLoadingRepositories,
@@ -224,9 +234,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<FormItem className="md:col-span-2 flex flex-col">
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
{field.value.gitlabPathNamespace && (
<Link
href={`https://gitlab.com/${field.value.owner}/${field.value.repo}`}
href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"

View File

@@ -128,6 +128,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
{container.status ? ` ${container.status}` : ""}
</SelectItem>
))}
</div>
@@ -143,6 +144,9 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
{container.currentState
? ` ${container.currentState}`
: ""}
</SelectItem>
))}
</>
@@ -152,6 +156,13 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
</SelectGroup>
</SelectContent>
</Select>
{option === "swarm" &&
services?.find((c) => c.containerId === containerId)?.error && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-3 py-2 text-sm text-destructive">
<span className="font-medium">Error: </span>
{services.find((c) => c.containerId === containerId)?.error}
</div>
)}
<DockerLogs
serverId={serverId || ""}
containerId={containerId || "select-a-container"}

View File

@@ -1,8 +1,8 @@
import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
import { Badge } from "@/components/ui/badge";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import {
Card,
CardContent,
@@ -93,6 +93,7 @@ export const ShowDockerLogsCompose = ({
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
{container.status ? ` ${container.status}` : ""}
</SelectItem>
))}
<SelectLabel>Containers ({data?.length})</SelectLabel>

View File

@@ -7,6 +7,7 @@ import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Form,
FormControl,
@@ -16,6 +17,7 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { validateAndFormatYAML } from "../application/advanced/traefik/update-traefik-config";
@@ -47,6 +49,7 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => {
},
);
const [canEdit, setCanEdit] = useState(true);
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
const { mutateAsync, isLoading, error, isError } =
api.settings.updateTraefikFile.useMutation();
@@ -66,13 +69,15 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => {
}, [form, form.reset, data]);
const onSubmit = async (data: UpdateServerMiddlewareConfig) => {
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
if (!valid) {
form.setError("traefikConfig", {
type: "manual",
message: error || "Invalid YAML",
});
return;
if (!skipYamlValidation) {
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
if (!valid) {
form.setError("traefikConfig", {
type: "manual",
message: error || "Invalid YAML",
});
return;
}
}
form.clearErrors("traefikConfig");
await mutateAsync({
@@ -153,14 +158,37 @@ routers:
/>
)}
</div>
<div className="flex justify-end">
<Button
isLoading={isLoading}
disabled={canEdit || isLoading}
type="submit"
>
Update
</Button>
<div className="flex flex-col gap-4">
<div className="flex items-center space-x-2">
<Checkbox
id="skip-yaml-validation"
checked={skipYamlValidation}
onCheckedChange={(checked) =>
setSkipYamlValidation(checked === true)
}
/>
<Label
htmlFor="skip-yaml-validation"
className="text-sm font-normal cursor-pointer"
>
Skip YAML validation (for Go templating)
</Label>
</div>
<p className="text-sm text-muted-foreground -mt-2">
Traefik supports Go templating in dynamic configs (e.g.{" "}
<code className="text-xs">{"{{range}}"}</code>). Configs using
templates will fail standard YAML validation. Check this to save
without validation.
</p>
<div className="flex justify-end">
<Button
isLoading={isLoading}
disabled={canEdit || isLoading}
type="submit"
>
Update
</Button>
</div>
</div>
</form>
</Form>

View File

@@ -73,8 +73,8 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
toast.success("External Port updated");
await refetch();
})
.catch(() => {
toast.error("Error saving the external port");
.catch((error: Error) => {
toast.error(error?.message || "Error saving the external port");
});
};

View File

@@ -73,8 +73,8 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
toast.success("External Port updated");
await refetch();
})
.catch(() => {
toast.error("Error saving the external port");
.catch((error: Error) => {
toast.error(error?.message || "Error saving the external port");
});
};

View File

@@ -73,8 +73,8 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
toast.success("External Port updated");
await refetch();
})
.catch(() => {
toast.error("Error saving the external port");
.catch((error: Error) => {
toast.error(error?.message || "Error saving the external port");
});
};

View File

@@ -129,7 +129,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
<FormItem>
<FormLabel>Docker Image</FormLabel>
<FormControl>
<Input placeholder="postgres:15" {...field} />
<Input placeholder="postgres:18" {...field} />
</FormControl>
<FormMessage />

View File

@@ -75,8 +75,8 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
toast.success("External Port updated");
await refetch();
})
.catch(() => {
toast.error("Error saving the external port");
.catch((error: Error) => {
toast.error(error?.message || "Error saving the external port");
});
};

View File

@@ -58,7 +58,7 @@ const dockerImageDefaultPlaceholder: Record<DbType, string> = {
mongo: "mongo:7",
mariadb: "mariadb:11",
mysql: "mysql:8",
postgres: "postgres:15",
postgres: "postgres:18",
redis: "redis:7",
};
@@ -559,6 +559,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
type="password"
placeholder="******************"
autoComplete="one-time-code"
enablePasswordGenerator={true}
{...field}
/>
</FormControl>
@@ -578,6 +579,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
<Input
type="password"
placeholder="******************"
enablePasswordGenerator={true}
{...field}
/>
</FormControl>

View File

@@ -288,9 +288,12 @@ export const ShowProjects = () => {
)
.some(Boolean);
const productionEnvironment = project?.environments.find(
(env) => env.isDefault,
);
// Find default environment from accessible environments, or fall back to first accessible environment
const accessibleEnvironment =
project?.environments.find((env) => env.isDefault) ||
project?.environments?.[0];
const hasNoEnvironments = !accessibleEnvironment;
return (
<div
@@ -298,7 +301,16 @@ export const ShowProjects = () => {
className="w-full lg:max-w-md"
>
<Link
href={`/dashboard/project/${project.projectId}/environment/${productionEnvironment?.environmentId}`}
href={
hasNoEnvironments
? "#"
: `/dashboard/project/${project.projectId}/environment/${accessibleEnvironment?.environmentId}`
}
onClick={(e) => {
if (hasNoEnvironments) {
e.preventDefault();
}
}}
>
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
{haveServicesWithDomains ? (
@@ -419,7 +431,7 @@ export const ShowProjects = () => {
) : null}
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2">
<span className="flex flex-col gap-1.5">
<span className="flex flex-col gap-1.5 ">
<div className="flex items-center gap-2">
<BookIcon className="size-4 text-muted-foreground" />
<span className="text-base font-medium leading-none">
@@ -427,9 +439,19 @@ export const ShowProjects = () => {
</span>
</div>
<span className="text-sm font-medium text-muted-foreground">
<span className="text-sm font-medium text-muted-foreground break-all">
{project.description}
</span>
{hasNoEnvironments && (
<div className="flex flex-row gap-2 items-center rounded-lg bg-yellow-50 p-2 mt-2 dark:bg-yellow-950">
<AlertTriangle className="size-4 text-yellow-600 dark:text-yellow-400 shrink-0" />
<span className="text-xs text-yellow-600 dark:text-yellow-400">
You have access to this project but no
environments are available
</span>
</div>
)}
</span>
<div className="flex self-start space-x-1">
<DropdownMenu>

View File

@@ -74,8 +74,8 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
toast.success("External Port updated");
await refetch();
})
.catch(() => {
toast.error("Error saving the external port");
.catch((error: Error) => {
toast.error(error?.message || "Error saving the external port");
});
};

View File

@@ -89,7 +89,7 @@ export const SearchCommand = () => {
<CommandGroup heading={"Projects"}>
<CommandList>
{data?.map((project) => {
// Find default environment, or fall back to first environment
// Find default environment from accessible environments, or fall back to first accessible environment
const defaultEnvironment =
project.environments.find(
(environment) => environment.isDefault,

View File

@@ -0,0 +1,74 @@
import { CreditCard, FileText } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { ShowInvoices } from "./show-invoices";
const navigationItems = [
{
name: "Subscription",
href: "/dashboard/settings/billing",
icon: CreditCard,
},
{
name: "Invoices",
href: "/dashboard/settings/invoices",
icon: FileText,
},
];
export const ShowBillingInvoices = () => {
const router = useRouter();
return (
<div className="w-full">
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<CreditCard className="size-6 text-muted-foreground self-center" />
Billing
</CardTitle>
<CardDescription>
Manage your subscription and invoices
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 py-4 border-t">
<nav className="flex space-x-2 border-b">
{navigationItems.map((item) => {
const Icon = item.icon;
const isActive = router.pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={cn(
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
isActive
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
)}
>
<Icon className="h-4 w-4" />
{item.name}
</Link>
);
})}
</nav>
<div className="mt-6">
<ShowInvoices />
</div>
</CardContent>
</div>
</Card>
</div>
);
};

View File

@@ -4,11 +4,13 @@ import {
AlertTriangle,
CheckIcon,
CreditCard,
FileText,
Loader2,
MinusIcon,
PlusIcon,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -37,7 +39,22 @@ export const calculatePrice = (count: number, isAnnual = false) => {
if (count <= 1) return 4.5;
return count * 3.5;
};
const navigationItems = [
{
name: "Subscription",
href: "/dashboard/settings/billing",
icon: CreditCard,
},
{
name: "Invoices",
href: "/dashboard/settings/invoices",
icon: FileText,
},
];
export const ShowBilling = () => {
const router = useRouter();
const { data: servers } = api.server.count.useQuery();
const { data: admin } = api.user.get.useQuery();
const { data, isLoading } = api.stripe.getProducts.useQuery();
@@ -76,17 +93,41 @@ export const ShowBilling = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<CreditCard className="size-6 text-muted-foreground self-center" />
Billing
</CardTitle>
<CardDescription>Manage your subscription</CardDescription>
<CardDescription>
Manage your subscription and invoices
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
<div className="flex flex-col gap-4 w-full">
<CardContent className="space-y-4 py-4 border-t">
<nav className="flex space-x-2 border-b">
{navigationItems.map((item) => {
const Icon = item.icon;
const isActive = router.pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={cn(
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
isActive
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
)}
>
<Icon className="h-4 w-4" />
{item.name}
</Link>
);
})}
</nav>
<div className="flex flex-col gap-4 w-full mt-6">
<Tabs
defaultValue="monthly"
value={isAnnual ? "annual" : "monthly"}

View File

@@ -0,0 +1,137 @@
import { Download, ExternalLink, FileText, Loader2 } from "lucide-react";
import type Stripe from "stripe";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
const formatDate = (timestamp: number | null) => {
if (!timestamp) return "-";
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
};
const formatAmount = (amount: number, currency: string) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
}).format(amount / 100);
};
const getStatusBadge = (status: Stripe.Invoice.Status | null) => {
const statusConfig: Record<
Stripe.Invoice.Status,
{ label: string; variant: "default" | "secondary" | "destructive" }
> = {
paid: { label: "Paid", variant: "default" },
open: { label: "Open", variant: "secondary" },
draft: { label: "Draft", variant: "secondary" },
void: { label: "Void", variant: "destructive" },
uncollectible: { label: "Uncollectible", variant: "destructive" },
};
if (!status) {
return <Badge variant="secondary">Unknown</Badge>;
}
const config = statusConfig[status] || {
label: status,
variant: "secondary" as const,
};
return <Badge variant={config.variant}>{config.label}</Badge>;
};
export const ShowInvoices = () => {
const { data: invoices, isLoading } = api.stripe.getInvoices.useQuery();
return (
<div className="space-y-4">
{isLoading ? (
<div className="flex items-center justify-center min-h-[20vh]">
<span className="text-base text-muted-foreground flex flex-row gap-3 items-center">
Loading invoices...
<Loader2 className="animate-spin" />
</span>
</div>
) : invoices && invoices.length > 0 ? (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Date</TableHead>
<TableHead>Due Date</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoices.map((invoice) => (
<TableRow key={invoice.id}>
<TableCell className="font-medium">
{invoice.number || invoice.id.slice(0, 12)}
</TableCell>
<TableCell>{formatDate(invoice.created)}</TableCell>
<TableCell>{formatDate(invoice.dueDate)}</TableCell>
<TableCell>
{formatAmount(invoice.amountDue, invoice.currency)}
</TableCell>
<TableCell>{getStatusBadge(invoice.status)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
{invoice.hostedInvoiceUrl && (
<Button
variant="outline"
size="sm"
onClick={() =>
window.open(
invoice.hostedInvoiceUrl || "",
"_blank",
)
}
>
<ExternalLink className="h-4 w-4" />
</Button>
)}
{invoice.invoicePdf && (
<Button
variant="outline"
size="sm"
onClick={() =>
window.open(invoice.invoicePdf || "", "_blank")
}
>
<Download className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="flex flex-col items-center justify-center min-h-[20vh] gap-2">
<FileText className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No invoices found</p>
<p className="text-sm text-muted-foreground">
Your invoices will appear here once you have a subscription
</p>
</div>
)}
</div>
);
};

View File

@@ -21,6 +21,7 @@ import {
FormControl,
FormField,
FormItem,
FormDescription,
FormLabel,
FormMessage,
} from "@/components/ui/form";
@@ -39,6 +40,10 @@ const Schema = z.object({
giteaUrl: z.string().min(1, {
message: "Gitea URL is required",
}),
giteaInternalUrl: z
.union([z.string().url(), z.literal("")])
.optional()
.transform((v) => (v === "" ? undefined : v)),
clientId: z.string().min(1, {
message: "Client ID is required",
}),
@@ -70,6 +75,7 @@ export const AddGiteaProvider = () => {
redirectUri: webhookUrl,
name: "",
giteaUrl: "https://gitea.com",
giteaInternalUrl: "",
},
resolver: zodResolver(Schema),
});
@@ -83,6 +89,7 @@ export const AddGiteaProvider = () => {
redirectUri: webhookUrl,
name: "",
giteaUrl: "https://gitea.com",
giteaInternalUrl: "",
});
}, [form, webhookUrl, isOpen]);
@@ -95,6 +102,7 @@ export const AddGiteaProvider = () => {
name: data.name,
redirectUri: data.redirectUri,
giteaUrl: data.giteaUrl,
giteaInternalUrl: data.giteaInternalUrl || undefined,
organizationName: data.organizationName,
})) as unknown as GiteaProviderResponse;
@@ -223,6 +231,29 @@ export const AddGiteaProvider = () => {
)}
/>
<FormField
control={form.control}
name="giteaInternalUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Internal URL (Optional)</FormLabel>
<FormControl>
<Input
placeholder="http://gitea:3000"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormDescription>
Use when Gitea runs on the same instance as Dokploy.
Used for OAuth token exchange to reach Gitea via
internal network (e.g. Docker service name).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="redirectUri"

View File

@@ -19,6 +19,7 @@ import {
FormControl,
FormField,
FormItem,
FormDescription,
FormLabel,
FormMessage,
} from "@/components/ui/form";
@@ -30,6 +31,10 @@ import { useUrl } from "@/utils/hooks/use-url";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
giteaUrl: z.string().min(1, "Gitea URL is required"),
giteaInternalUrl: z
.union([z.string().url(), z.literal("")])
.optional()
.transform((v) => (v === "" ? undefined : v)),
clientId: z.string().min(1, "Client ID is required"),
clientSecret: z.string().min(1, "Client Secret is required"),
});
@@ -94,6 +99,7 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
defaultValues: {
name: "",
giteaUrl: "https://gitea.com",
giteaInternalUrl: "",
clientId: "",
clientSecret: "",
},
@@ -104,6 +110,7 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
form.reset({
name: gitea.gitProvider?.name || "",
giteaUrl: gitea.giteaUrl || "https://gitea.com",
giteaInternalUrl: gitea.giteaInternalUrl || "",
clientId: gitea.clientId || "",
clientSecret: gitea.clientSecret || "",
});
@@ -116,6 +123,7 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
gitProviderId: gitea?.gitProvider?.gitProviderId || "",
name: values.name,
giteaUrl: values.giteaUrl,
giteaInternalUrl: values.giteaInternalUrl ?? null,
clientId: values.clientId,
clientSecret: values.clientSecret,
})
@@ -224,6 +232,28 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="giteaInternalUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Internal URL (Optional)</FormLabel>
<FormControl>
<Input
placeholder="http://gitea:3000"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormDescription>
Use when Gitea runs on the same instance as Dokploy. Used
for OAuth token exchange to reach Gitea via internal network
(e.g. Docker service name).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientId"

View File

@@ -21,6 +21,7 @@ import {
FormControl,
FormField,
FormItem,
FormDescription,
FormLabel,
FormMessage,
} from "@/components/ui/form";
@@ -35,6 +36,10 @@ const Schema = z.object({
gitlabUrl: z.string().min(1, {
message: "GitLab URL is required",
}),
gitlabInternalUrl: z
.union([z.string().url(), z.literal("")])
.optional()
.transform((v) => (v === "" ? undefined : v)),
applicationId: z.string().min(1, {
message: "Application ID is required",
}),
@@ -66,6 +71,7 @@ export const AddGitlabProvider = () => {
redirectUri: webhookUrl,
name: "",
gitlabUrl: "https://gitlab.com",
gitlabInternalUrl: "",
},
resolver: zodResolver(Schema),
});
@@ -80,6 +86,7 @@ export const AddGitlabProvider = () => {
redirectUri: webhookUrl,
name: "",
gitlabUrl: "https://gitlab.com",
gitlabInternalUrl: "",
});
}, [form, isOpen]);
@@ -92,6 +99,7 @@ export const AddGitlabProvider = () => {
name: data.name || "",
redirectUri: data.redirectUri || "",
gitlabUrl: data.gitlabUrl || "https://gitlab.com",
gitlabInternalUrl: data.gitlabInternalUrl || undefined,
})
.then(async () => {
await utils.gitProvider.getAll.invalidate();
@@ -192,6 +200,29 @@ export const AddGitlabProvider = () => {
)}
/>
<FormField
control={form.control}
name="gitlabInternalUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Internal URL (Optional)</FormLabel>
<FormControl>
<Input
placeholder="http://gitlab:80"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormDescription>
Use when GitLab runs on the same instance as Dokploy.
Used for OAuth token exchange to reach GitLab via
internal network (e.g. Docker service name).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="redirectUri"

View File

@@ -20,6 +20,7 @@ import {
FormControl,
FormField,
FormItem,
FormDescription,
FormLabel,
FormMessage,
} from "@/components/ui/form";
@@ -33,6 +34,10 @@ const Schema = z.object({
gitlabUrl: z.string().url({
message: "Invalid Gitlab URL",
}),
gitlabInternalUrl: z
.union([z.string().url(), z.literal("")])
.optional()
.transform((v) => (v === "" ? undefined : v)),
groupName: z.string().optional(),
});
@@ -61,6 +66,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
groupName: "",
name: "",
gitlabUrl: "https://gitlab.com",
gitlabInternalUrl: "",
},
resolver: zodResolver(Schema),
});
@@ -72,6 +78,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
groupName: gitlab?.groupName || "",
name: gitlab?.gitProvider.name || "",
gitlabUrl: gitlab?.gitlabUrl || "",
gitlabInternalUrl: gitlab?.gitlabInternalUrl || "",
});
}, [form, isOpen]);
@@ -82,6 +89,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
groupName: data.groupName || "",
name: data.name || "",
gitlabUrl: data.gitlabUrl || "",
gitlabInternalUrl: data.gitlabInternalUrl ?? null,
})
.then(async () => {
await utils.gitProvider.getAll.invalidate();
@@ -151,6 +159,29 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
)}
/>
<FormField
control={form.control}
name="gitlabInternalUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Internal URL (Optional)</FormLabel>
<FormControl>
<Input
placeholder="http://gitlab:80"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormDescription>
Use when GitLab runs on the same instance as Dokploy.
Used for OAuth token exchange to reach GitLab via
internal network (e.g. Docker service name).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="groupName"

View File

@@ -15,6 +15,8 @@ import {
GotifyIcon,
LarkIcon,
NtfyIcon,
PushoverIcon,
ResendIcon,
SlackIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
@@ -96,6 +98,23 @@ export const notificationSchema = z.discriminatedUnion("type", [
.min(1, { message: "At least one email is required" }),
})
.merge(notificationBaseSchema),
z
.object({
type: z.literal("resend"),
apiKey: z.string().min(1, { message: "API Key is required" }),
fromAddress: z
.string()
.min(1, { message: "From Address is required" })
.email({ message: "Email is invalid" }),
toAddresses: z
.array(
z.string().min(1, { message: "Email is required" }).email({
message: "Email is invalid",
}),
)
.min(1, { message: "At least one email is required" }),
})
.merge(notificationBaseSchema),
z
.object({
type: z.literal("gotify"),
@@ -114,6 +133,16 @@ export const notificationSchema = z.discriminatedUnion("type", [
priority: z.number().min(1).max(5).default(3),
})
.merge(notificationBaseSchema),
z
.object({
type: z.literal("pushover"),
userKey: z.string().min(1, { message: "User Key is required" }),
apiToken: z.string().min(1, { message: "API Token is required" }),
priority: z.number().min(-2).max(2).default(0),
retry: z.number().min(30).nullish(),
expire: z.number().min(1).max(10800).nullish(),
})
.merge(notificationBaseSchema),
z
.object({
type: z.literal("custom"),
@@ -158,6 +187,10 @@ export const notificationsMap = {
icon: <Mail size={29} className="text-muted-foreground" />,
label: "Email",
},
resend: {
icon: <ResendIcon className="text-muted-foreground" />,
label: "Resend",
},
gotify: {
icon: <GotifyIcon />,
label: "Gotify",
@@ -166,6 +199,10 @@ export const notificationsMap = {
icon: <NtfyIcon />,
label: "ntfy",
},
pushover: {
icon: <PushoverIcon />,
label: "Pushover",
},
custom: {
icon: <PenBoxIcon size={29} className="text-muted-foreground" />,
label: "Custom",
@@ -199,6 +236,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
api.notification.testDiscordConnection.useMutation();
const { mutateAsync: testEmailConnection, isLoading: isLoadingEmail } =
api.notification.testEmailConnection.useMutation();
const { mutateAsync: testResendConnection, isLoading: isLoadingResend } =
api.notification.testResendConnection.useMutation();
const { mutateAsync: testGotifyConnection, isLoading: isLoadingGotify } =
api.notification.testGotifyConnection.useMutation();
const { mutateAsync: testNtfyConnection, isLoading: isLoadingNtfy } =
@@ -209,6 +248,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } =
api.notification.testCustomConnection.useMutation();
const { mutateAsync: testPushoverConnection, isLoading: isLoadingPushover } =
api.notification.testPushoverConnection.useMutation();
const customMutation = notificationId
? api.notification.updateCustom.useMutation()
: api.notification.createCustom.useMutation();
@@ -224,6 +266,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
const emailMutation = notificationId
? api.notification.updateEmail.useMutation()
: api.notification.createEmail.useMutation();
const resendMutation = notificationId
? api.notification.updateResend.useMutation()
: api.notification.createResend.useMutation();
const gotifyMutation = notificationId
? api.notification.updateGotify.useMutation()
: api.notification.createGotify.useMutation();
@@ -233,6 +278,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
const larkMutation = notificationId
? api.notification.updateLark.useMutation()
: api.notification.createLark.useMutation();
const pushoverMutation = notificationId
? api.notification.updatePushover.useMutation()
: api.notification.createPushover.useMutation();
const form = useForm<NotificationSchema>({
defaultValues: {
@@ -260,7 +308,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
});
useEffect(() => {
if (type === "email" && fields.length === 0) {
if ((type === "email" || type === "resend") && fields.length === 0) {
append("");
}
}, [type, append, fields.length]);
@@ -328,6 +376,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "resend") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
apiKey: notification.resend?.apiKey,
toAddresses: notification.resend?.toAddresses,
fromAddress: notification.resend?.fromAddress,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "gotify") {
form.reset({
appBuildError: notification.appBuildError,
@@ -393,6 +456,23 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "pushover") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
userKey: notification.pushover?.userKey,
apiToken: notification.pushover?.apiToken,
priority: notification.pushover?.priority,
retry: notification.pushover?.retry ?? undefined,
expire: notification.pushover?.expire ?? undefined,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
}
} else {
form.reset();
@@ -404,10 +484,12 @@ export const HandleNotifications = ({ notificationId }: Props) => {
telegram: telegramMutation,
discord: discordMutation,
email: emailMutation,
resend: resendMutation,
gotify: gotifyMutation,
ntfy: ntfyMutation,
lark: larkMutation,
custom: customMutation,
pushover: pushoverMutation,
};
const onSubmit = async (data: NotificationSchema) => {
@@ -486,6 +568,22 @@ export const HandleNotifications = ({ notificationId }: Props) => {
emailId: notification?.emailId || "",
serverThreshold: serverThreshold,
});
} else if (data.type === "resend") {
promise = resendMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
apiKey: data.apiKey,
fromAddress: data.fromAddress,
toAddresses: data.toAddresses,
name: data.name,
dockerCleanup: dockerCleanup,
notificationId: notificationId || "",
resendId: notification?.resendId || "",
serverThreshold: serverThreshold,
});
} else if (data.type === "gotify") {
promise = gotifyMutation.mutateAsync({
appBuildError: appBuildError,
@@ -559,6 +657,28 @@ export const HandleNotifications = ({ notificationId }: Props) => {
notificationId: notificationId || "",
customId: notification?.customId || "",
});
} else if (data.type === "pushover") {
if (data.priority === 2 && (data.retry == null || data.expire == null)) {
toast.error("Retry and expire are required for emergency priority (2)");
return;
}
promise = pushoverMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
userKey: data.userKey,
apiToken: data.apiToken,
priority: data.priority,
retry: data.priority === 2 ? data.retry : undefined,
expire: data.priority === 2 ? data.expire : undefined,
name: data.name,
dockerCleanup: dockerCleanup,
serverThreshold: serverThreshold,
notificationId: notificationId || "",
pushoverId: notification?.pushoverId || "",
});
}
if (promise) {
@@ -981,6 +1101,96 @@ export const HandleNotifications = ({ notificationId }: Props) => {
</>
)}
{type === "resend" && (
<>
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder="re_********"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fromAddress"
render={({ field }) => (
<FormItem>
<FormLabel>From Address</FormLabel>
<FormControl>
<Input placeholder="from@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col gap-2 pt-2">
<FormLabel>To Addresses</FormLabel>
{fields.map((field, index) => (
<div
key={field.id}
className="flex flex-row gap-2 w-full"
>
<FormField
control={form.control}
name={`toAddresses.${index}`}
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Input
placeholder="email@example.com"
className="w-full"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="outline"
type="button"
onClick={() => {
remove(index);
}}
>
Remove
</Button>
</div>
))}
{type === "resend" &&
"toAddresses" in form.formState.errors && (
<div className="text-sm font-medium text-destructive">
{form.formState?.errors?.toAddresses?.root?.message}
</div>
)}
</div>
<Button
variant="outline"
type="button"
onClick={() => {
append("");
}}
>
Add
</Button>
</>
)}
{type === "gotify" && (
<>
<FormField
@@ -1255,6 +1465,147 @@ export const HandleNotifications = ({ notificationId }: Props) => {
/>
</>
)}
{type === "pushover" && (
<>
<FormField
control={form.control}
name="userKey"
render={({ field }) => (
<FormItem>
<FormLabel>User Key</FormLabel>
<FormControl>
<Input placeholder="ub3de9kl2q..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apiToken"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input placeholder="a3d9k2q7m4..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
defaultValue={0}
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Priority</FormLabel>
<FormControl>
<Input
placeholder="0"
value={field.value ?? 0}
onChange={(e) => {
const value = e.target.value;
if (value === "" || value === "-") {
field.onChange(0);
} else {
const priority = Number.parseInt(value);
if (
!Number.isNaN(priority) &&
priority >= -2 &&
priority <= 2
) {
field.onChange(priority);
}
}
}}
type="number"
min={-2}
max={2}
/>
</FormControl>
<FormDescription>
Message priority (-2 to 2, default: 0, emergency: 2)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.watch("priority") === 2 && (
<>
<FormField
control={form.control}
name="retry"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Retry (seconds)</FormLabel>
<FormControl>
<Input
placeholder="30"
{...field}
value={field.value ?? ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(undefined);
} else {
const retry = Number.parseInt(value);
if (!Number.isNaN(retry)) {
field.onChange(retry);
}
}
}}
type="number"
min={30}
/>
</FormControl>
<FormDescription>
How often (in seconds) to retry. Minimum 30
seconds.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expire"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Expire (seconds)</FormLabel>
<FormControl>
<Input
placeholder="3600"
{...field}
value={field.value ?? ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(undefined);
} else {
const expire = Number.parseInt(value);
if (!Number.isNaN(expire)) {
field.onChange(expire);
}
}
}}
type="number"
min={1}
max={10800}
/>
</FormControl>
<FormDescription>
How long to keep retrying (max 10800 seconds / 3
hours).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</>
)}
</div>
</div>
<div className="flex flex-col gap-4">
@@ -1425,10 +1776,12 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingTelegram ||
isLoadingDiscord ||
isLoadingEmail ||
isLoadingResend ||
isLoadingGotify ||
isLoadingNtfy ||
isLoadingLark ||
isLoadingCustom
isLoadingCustom ||
isLoadingPushover
}
variant="secondary"
type="button"
@@ -1464,6 +1817,12 @@ export const HandleNotifications = ({ notificationId }: Props) => {
fromAddress: data.fromAddress,
toAddresses: data.toAddresses,
});
} else if (data.type === "resend") {
await testResendConnection({
apiKey: data.apiKey,
fromAddress: data.fromAddress,
toAddresses: data.toAddresses,
});
} else if (data.type === "gotify") {
await testGotifyConnection({
serverUrl: data.serverUrl,
@@ -1497,6 +1856,22 @@ export const HandleNotifications = ({ notificationId }: Props) => {
endpoint: data.endpoint,
headers: headersRecord,
});
} else if (data.type === "pushover") {
if (
data.priority === 2 &&
(data.retry == null || data.expire == null)
) {
throw new Error(
"Retry and expire are required for emergency priority (2)",
);
}
await testPushoverConnection({
userKey: data.userKey,
apiToken: data.apiToken,
priority: data.priority,
retry: data.priority === 2 ? data.retry : undefined,
expire: data.priority === 2 ? data.expire : undefined,
});
}
toast.success("Connection Success");
} catch (error) {

View File

@@ -5,6 +5,7 @@ import {
GotifyIcon,
LarkIcon,
NtfyIcon,
ResendIcon,
SlackIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
@@ -36,7 +37,7 @@ export const ShowNotifications = () => {
</CardTitle>
<CardDescription>
Add your providers to receive notifications, like Discord, Slack,
Telegram, Email, Lark.
Telegram, Email, Resend, Lark.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
@@ -86,6 +87,11 @@ export const ShowNotifications = () => {
<Mail className="size-6 text-muted-foreground" />
</div>
)}
{notification.notificationType === "resend" && (
<div className="flex items-center justify-center rounded-lg ">
<ResendIcon className="size-6 text-muted-foreground" />
</div>
)}
{notification.notificationType === "gotify" && (
<div className="flex items-center justify-center rounded-lg ">
<GotifyIcon className="size-6" />

View File

@@ -41,7 +41,7 @@ const profileSchema = z.object({
password: z.string().nullable(),
currentPassword: z.string().nullable(),
image: z.string().optional(),
name: z.string().optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
allowImpersonation: z.boolean().optional().default(false),
});
@@ -91,7 +91,7 @@ export const ProfileForm = () => {
image: data?.user?.image || "",
currentPassword: "",
allowImpersonation: data?.user?.allowImpersonation || false,
name: data?.user?.firstName || "",
firstName: data?.user?.firstName || "",
lastName: data?.user?.lastName || "",
},
resolver: zodResolver(profileSchema),
@@ -106,7 +106,7 @@ export const ProfileForm = () => {
image: data?.user?.image || "",
currentPassword: form.getValues("currentPassword") || "",
allowImpersonation: data?.user?.allowImpersonation,
name: data?.user?.firstName || "",
firstName: data?.user?.firstName || "",
lastName: data?.user?.lastName || "",
},
{
@@ -131,7 +131,7 @@ export const ProfileForm = () => {
image: values.image,
currentPassword: values.currentPassword || undefined,
allowImpersonation: values.allowImpersonation,
name: values.name || undefined,
firstName: values.firstName || undefined,
lastName: values.lastName || undefined,
});
await refetch();
@@ -141,7 +141,7 @@ export const ProfileForm = () => {
password: "",
image: values.image,
currentPassword: "",
name: values.name || "",
firstName: values.firstName || "",
lastName: values.lastName || "",
});
} catch (error) {
@@ -184,7 +184,7 @@ export const ProfileForm = () => {
<div className="space-y-4">
<FormField
control={form.control}
name="name"
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>First Name</FormLabel>

View File

@@ -23,6 +23,8 @@ export const ShowDokployActions = () => {
const { mutateAsync: cleanRedis } = api.settings.cleanRedis.useMutation();
const { mutateAsync: reloadRedis } = api.settings.reloadRedis.useMutation();
const { mutateAsync: cleanAllDeploymentQueue } =
api.settings.cleanAllDeploymentQueue.useMutation();
return (
<DropdownMenu>
@@ -87,6 +89,21 @@ export const ShowDokployActions = () => {
Clean Redis
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={async () => {
await cleanAllDeploymentQueue()
.then(() => {
toast.success("Deployment queue cleaned");
})
.catch(() => {
toast.error("Error cleaning deployment queue");
});
}}
>
Clean all deployment queue
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={async () => {

View File

@@ -12,6 +12,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
import { api } from "@/utils/api";
import { EditTraefikEnv } from "../../web-server/edit-traefik-env";
import { ManageTraefikPorts } from "../../web-server/manage-traefik-ports";
@@ -33,14 +34,45 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
serverId,
});
const {
execute: executeWithHealthCheck,
isExecuting: isHealthCheckExecuting,
} = useHealthCheckAfterMutation({
initialDelay: 5000,
pollInterval: 4000,
successMessage: "Traefik dashboard updated successfully",
onSuccess: () => {
refetchDashboard();
},
});
const {
execute: executeReloadWithHealthCheck,
isExecuting: isReloadHealthCheckExecuting,
} = useHealthCheckAfterMutation({
initialDelay: 5000,
pollInterval: 4000,
successMessage: "Traefik Reloaded",
});
return (
<DropdownMenu>
<DropdownMenuTrigger
asChild
disabled={reloadTraefikIsLoading || toggleDashboardIsLoading}
disabled={
reloadTraefikIsLoading ||
toggleDashboardIsLoading ||
isHealthCheckExecuting ||
isReloadHealthCheckExecuting
}
>
<Button
isLoading={reloadTraefikIsLoading || toggleDashboardIsLoading}
isLoading={
reloadTraefikIsLoading ||
toggleDashboardIsLoading ||
isHealthCheckExecuting ||
isReloadHealthCheckExecuting
}
variant="outline"
>
{t("settings.server.webServer.traefik.label")}
@@ -54,15 +86,19 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
<DropdownMenuGroup>
<DropdownMenuItem
onClick={async () => {
await reloadTraefik({
serverId: serverId,
})
.then(async () => {
toast.success("Traefik Reloaded");
})
.catch(() => {});
try {
await executeReloadWithHealthCheck(() =>
reloadTraefik({ serverId }),
);
} catch (error) {
const errorMessage =
(error as Error)?.message ||
"Failed to reload Traefik. Please try again.";
toast.error(errorMessage);
}
}}
className="cursor-pointer"
disabled={isReloadHealthCheckExecuting}
>
<span>{t("settings.server.webServer.reload")}</span>
</DropdownMenuItem>
@@ -108,24 +144,21 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
</div>
}
onClick={async () => {
await toggleDashboard({
enableDashboard: !haveTraefikDashboardPortEnabled,
serverId: serverId,
})
.then(async () => {
toast.success(
`${haveTraefikDashboardPortEnabled ? "Disabled" : "Enabled"} Dashboard`,
);
refetchDashboard();
})
.catch((error) => {
const errorMessage =
error?.message ||
"Failed to toggle dashboard. Please check if port 8080 is available.";
toast.error(errorMessage);
});
try {
await executeWithHealthCheck(() =>
toggleDashboard({
enableDashboard: !haveTraefikDashboardPortEnabled,
serverId: serverId,
}),
);
} catch (error) {
const errorMessage =
(error as Error)?.message ||
"Failed to toggle dashboard. Please check if port 8080 is available.";
toast.error(errorMessage);
}
}}
disabled={toggleDashboardIsLoading}
disabled={toggleDashboardIsLoading || isHealthCheckExecuting}
type="default"
>
<DropdownMenuItem

View File

@@ -22,7 +22,6 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
@@ -89,15 +88,15 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => {
</Button>
</DialogTrigger>
) : (
<DropdownMenuItem
<Button
className="w-full cursor-pointer "
onSelect={(e) => {
e.preventDefault();
size="sm"
onClick={() => {
setIsOpen(true);
}}
>
Setup Server
</DropdownMenuItem>
Setup Server <Settings className="size-4" />
</Button>
)}
<DialogContent className="sm:max-w-4xl ">
<DialogHeader>

View File

@@ -6,9 +6,7 @@ import {
Loader2,
MoreHorizontal,
Network,
Pencil,
ServerIcon,
Settings,
Terminal,
Trash2,
User,
@@ -31,9 +29,7 @@ import {
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
@@ -285,7 +281,32 @@ export const ShowServers = () => {
{/* Compact Actions */}
{isActive && (
<div className="flex items-center gap-2 pt-3 border-t mt-auto">
<div className="flex items-center gap-2 pt-3 border-t mt-auto flex-wrap">
<div className="flex items-center gap-2 w-full">
<Tooltip>
<TooltipTrigger asChild>
<SetupServer
serverId={server.serverId}
/>
</TooltipTrigger>
<TooltipContent
className="max-w-xs"
side="bottom"
>
<div className="space-y-1">
<p className="font-semibold">
Setup Server
</p>
<p className="text-xs text-muted-foreground">
Configure and initialize your
server with Docker, Traefik, and
other essential services
</p>
</div>
</TooltipContent>
</Tooltip>
</div>
<TooltipProvider>
{server.sshKeyId && (
<Tooltip>
@@ -311,20 +332,6 @@ export const ShowServers = () => {
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<div>
<SetupServer
serverId={server.serverId}
asButton={true}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Setup Server</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div>

View File

@@ -1,6 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
@@ -46,6 +47,14 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
const { mutateAsync, isLoading, error, isError } =
api.settings.writeTraefikEnv.useMutation();
const {
execute: executeWithHealthCheck,
isExecuting: isHealthCheckExecuting,
} = useHealthCheckAfterMutation({
initialDelay: 5000,
successMessage: "Traefik Env Updated",
});
const form = useForm<Schema>({
defaultValues: {
env: data || "",
@@ -63,16 +72,16 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
}, [form, form.reset, data]);
const onSubmit = async (data: Schema) => {
await mutateAsync({
env: data.env,
serverId,
})
.then(async () => {
toast.success("Traefik Env Updated");
})
.catch(() => {
toast.error("Error updating the Traefik env");
});
try {
await executeWithHealthCheck(() =>
mutateAsync({
env: data.env,
serverId,
}),
);
} catch {
toast.error("Error updating the Traefik env");
}
};
// Add keyboard shortcut for Ctrl+S/Cmd+S
@@ -154,8 +163,8 @@ TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_HTTP_CHALLENGE_DNS_PROVIDER=cloudflare
<DialogFooter>
<Button
isLoading={isLoading}
disabled={canEdit || isLoading}
isLoading={isLoading || isHealthCheckExecuting}
disabled={canEdit || isLoading || isHealthCheckExecuting}
form="hook-form-update-server-traefik-config"
type="submit"
>

View File

@@ -1,6 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRightLeft, Plus, Trash2 } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
import type React from "react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
@@ -76,11 +77,19 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
});
const { mutateAsync: updatePorts, isLoading } =
api.settings.updateTraefikPorts.useMutation({
onSuccess: () => {
refetchPorts();
},
});
api.settings.updateTraefikPorts.useMutation();
const {
execute: executeWithHealthCheck,
isExecuting: isHealthCheckExecuting,
} = useHealthCheckAfterMutation({
initialDelay: 5000,
successMessage: t("settings.server.webServer.traefik.portsUpdated"),
onSuccess: () => {
refetchPorts();
setOpen(false);
},
});
useEffect(() => {
if (currentPorts) {
@@ -99,11 +108,12 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
const onSubmit = async (data: TraefikPortsForm) => {
try {
await updatePorts({
serverId,
additionalPorts: data.ports,
});
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
await executeWithHealthCheck(() =>
updatePorts({
serverId,
additionalPorts: data.ports,
}),
);
setOpen(false);
} catch (error) {
toast.error((error as Error).message || "Error updating Traefik ports");
@@ -317,7 +327,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
type="submit"
variant="default"
className="text-sm"
isLoading={isLoading}
isLoading={isLoading || isHealthCheckExecuting}
>
Save
</Button>

View File

@@ -231,3 +231,49 @@ export const NtfyIcon = ({ className }: Props) => {
</svg>
);
};
export const PushoverIcon = ({ className }: Props) => {
return (
<svg
viewBox="0 0 600 600"
className={cn("size-8", className)}
xmlns="http://www.w3.org/2000/svg"
>
<g stroke="none" strokeWidth="1">
<ellipse
style={{ fillRule: "evenodd" }}
fill="#249DF1"
transform="matrix(-0.674571, 0.73821, -0.73821, -0.674571, 556.833239, 241.613465)"
cx="216.308"
cy="152.076"
rx="296.855"
ry="296.855"
/>
<path
fill="#FFFFFF"
d="M 280.949 172.514 L 355.429 162.714 L 282.909 326.374 L 282.909 326.374 C 295.649 325.394 308.142 321.067 320.389 313.394 L 320.389 313.394 L 320.389 313.394 C 332.642 305.714 343.916 296.077 354.209 284.484 L 354.209 284.484 L 354.209 284.484 C 364.496 272.884 373.396 259.981 380.909 245.774 L 380.909 245.774 L 380.909 245.774 C 388.422 231.561 393.812 217.594 397.079 203.874 L 397.079 203.874 L 397.079 203.874 C 399.039 195.381 399.939 187.214 399.779 179.374 L 399.779 179.374 L 399.779 179.374 C 399.612 171.534 397.569 164.674 393.649 158.794 L 393.649 158.794 L 393.649 158.794 C 389.729 152.914 383.766 148.177 375.759 144.584 L 375.759 144.584 L 375.759 144.584 C 367.759 140.991 356.899 139.194 343.179 139.194 L 343.179 139.194 L 343.179 139.194 C 327.172 139.194 311.409 141.807 295.889 147.034 L 295.889 147.034 L 295.889 147.034 C 280.376 152.261 266.002 159.857 252.769 169.824 L 252.769 169.824 L 252.769 169.824 C 239.542 179.784 228.029 192.197 218.229 207.064 L 218.229 207.064 L 218.229 207.064 C 208.429 221.924 201.406 238.827 197.159 257.774 L 197.159 257.774 L 197.159 257.774 C 195.526 263.981 194.546 268.961 194.219 272.714 L 194.219 272.714 L 194.219 272.714 C 193.892 276.474 193.812 279.577 193.979 282.024 L 193.979 282.024 L 193.979 282.024 C 194.139 284.477 194.462 286.357 194.949 287.664 L 194.949 287.664 L 194.949 287.664 C 195.442 288.971 195.852 290.277 196.179 291.584 L 196.179 291.584 L 196.179 291.584 C 179.519 291.584 167.349 288.234 159.669 281.534 L 159.669 281.534 L 159.669 281.534 C 151.996 274.841 150.119 263.164 154.039 246.504 L 154.039 246.504 L 154.039 246.504 C 157.959 229.191 166.862 212.694 180.749 197.014 L 180.749 197.014 L 180.749 197.014 C 194.629 181.334 211.122 167.531 230.229 155.604 L 230.229 155.604 L 230.229 155.604 C 249.342 143.684 270.249 134.214 292.949 127.194 L 292.949 127.194 L 292.949 127.194 C 315.656 120.167 337.789 116.654 359.349 116.654 L 359.349 116.654 L 359.349 116.654 C 378.296 116.654 394.219 119.347 407.119 124.734 L 407.119 124.734 L 407.119 124.734 C 420.026 130.127 430.072 137.234 437.259 146.054 L 437.259 146.054 L 437.259 146.054 C 444.446 154.874 448.936 165.164 450.729 176.924 L 450.729 176.924 L 450.729 176.924 C 452.529 188.684 451.959 200.934 449.019 213.674 L 449.019 213.674 L 449.019 213.674 C 445.426 229.027 438.646 244.464 428.679 259.984 L 428.679 259.984 L 428.679 259.984 C 418.719 275.497 406.226 289.544 391.199 302.124 L 391.199 302.124 L 391.199 302.124 C 376.172 314.697 358.939 324.904 339.499 332.744 L 339.499 332.744 L 339.499 332.744 C 320.066 340.584 299.406 344.504 277.519 344.504 L 277.519 344.504 L 275.069 344.504 L 212.839 484.154 L 142.279 484.154 L 280.949 172.514 Z"
/>
</g>
</svg>
);
};
export const ResendIcon = ({ className }: Props) => {
return (
<svg
viewBox="0 0 24 24"
className={cn("size-8", className)}
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.12" />
<path
d="M8 17V7h6a3 3 0 0 1 0 6H8m6 0 2 4"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</svg>
);
};

View File

@@ -18,9 +18,10 @@ import {
Forward,
GalleryVerticalEnd,
GitBranch,
HeartIcon,
Key,
KeyRound,
Loader2,
LogIn,
type LucideIcon,
Package,
PieChart,
@@ -397,6 +398,24 @@ const MENU: Menu = {
// Only enabled for admins in cloud environments
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud),
},
{
isSingle: true,
title: "License",
url: "/dashboard/settings/license",
icon: Key,
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
{
isSingle: true,
title: "SSO",
url: "/dashboard/settings/sso",
icon: LogIn,
// Enabled for admins in both cloud and self-hosted (enterprise)
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
],
help: [
@@ -410,18 +429,6 @@ const MENU: Menu = {
url: "https://discord.gg/2tBnJ3jDJc",
icon: CircleHelp,
},
{
name: "Sponsor",
url: "https://opencollective.com/dokploy",
icon: ({ className }) => (
<HeartIcon
className={cn(
"text-red-500 fill-red-600 animate-heartbeat",
className,
)}
/>
),
},
],
} as const;

View File

@@ -0,0 +1,47 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
export function SignInWithGithub() {
const [isLoading, setIsLoading] = useState(false);
const handleClick = async () => {
setIsLoading(true);
try {
const { error } = await authClient.signIn.social({
provider: "github",
});
if (error) {
toast.error(error.message);
return;
}
} catch (err) {
toast.error("An error occurred while signing in with GitHub", {
description: err instanceof Error ? err.message : "Unknown error",
});
} finally {
setIsLoading(false);
}
};
return (
<Button
variant="outline"
type="button"
className="w-full mb-4"
onClick={handleClick}
isLoading={isLoading}
>
<svg viewBox="0 0 438.549 438.549" className="mr-2 size-4">
<path
fill="currentColor"
d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"
/>
</svg>
Sign in with GitHub
</Button>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
export function SignInWithGoogle() {
const [isLoading, setIsLoading] = useState(false);
const handleClick = async () => {
setIsLoading(true);
try {
const { error } = await authClient.signIn.social({
provider: "google",
});
if (error) {
toast.error(error.message);
return;
}
} catch (err) {
toast.error("An error occurred while signing in with Google", {
description: err instanceof Error ? err.message : "Unknown error",
});
} finally {
setIsLoading(false);
}
};
return (
<Button
variant="outline"
type="button"
className="w-full mb-4"
onClick={handleClick}
isLoading={isLoading}
>
<svg viewBox="0 0 24 24" className="mr-2 size-4">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Sign in with Google
</Button>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import { Loader2, Lock } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
interface EnterpriseFeatureLockedProps {
/** Optional title override */
title?: string;
/** Optional description override */
description?: string;
/** Optional custom CTA label */
ctaLabel?: string;
/** Optional CTA href (default: /dashboard/settings/license) */
ctaHref?: string;
/** Compact variant (less padding, smaller icon) */
compact?: boolean;
}
/**
* Displays a locked state for enterprise features when the user has no valid license.
* Use standalone or via EnterpriseFeatureGate.
*/
export function EnterpriseFeatureLocked({
title = "Enterprise feature",
description = "This feature is part of Dokploy Enterprise. Add a valid license to use it.",
ctaLabel = "Go to License",
ctaHref = "/dashboard/settings/license",
compact = false,
}: EnterpriseFeatureLockedProps) {
return (
<Card className="border-dashed bg-transparent">
<CardHeader className={compact ? "pb-2" : undefined}>
<div className="flex flex-col items-center gap-3 text-center">
<div
className={
compact
? "rounded-full bg-muted p-3"
: "rounded-full bg-muted p-4"
}
>
<Lock
className={
compact
? "size-6 text-muted-foreground"
: "size-8 text-muted-foreground"
}
/>
</div>
<div className="space-y-1">
<CardTitle className="text-lg">{title}</CardTitle>
<CardDescription className="max-w-sm mx-auto">
{description}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className={compact ? "pt-0" : undefined}>
<div className="flex justify-center">
<Button asChild variant="secondary" size={compact ? "sm" : "default"}>
<Link href={ctaHref}>{ctaLabel}</Link>
</Button>
</div>
</CardContent>
</Card>
);
}
interface EnterpriseFeatureGateProps {
children: React.ReactNode;
/** Props for the locked state when license is invalid */
lockedProps?: Omit<EnterpriseFeatureLockedProps, "compact">;
/** Show loading spinner while checking license */
fallback?: React.ReactNode;
}
/**
* Renders children only when the instance has a valid enterprise license.
* Otherwise shows EnterpriseFeatureLocked.
*/
export function EnterpriseFeatureGate({
children,
lockedProps,
fallback,
}: EnterpriseFeatureGateProps) {
const { data: haveValidLicense, isLoading } =
api.licenseKey.haveValidLicenseKey.useQuery();
if (isLoading) {
if (fallback) return <>{fallback}</>;
return (
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
<Loader2 className="size-6 text-muted-foreground animate-spin" />
<span className="text-sm text-muted-foreground">
Checking license...
</span>
</div>
);
}
if (!haveValidLicense) {
return <EnterpriseFeatureLocked {...lockedProps} />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,232 @@
import { Key, Loader2, ShieldCheck } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import { CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
export function LicenseKeySettings() {
const utils = api.useUtils();
const { data, isLoading } = api.licenseKey.getEnterpriseSettings.useQuery();
const { mutateAsync: updateEnterpriseSettings, isLoading: isSaving } =
api.licenseKey.updateEnterpriseSettings.useMutation();
const { mutateAsync: activateLicenseKey, isLoading: isActivating } =
api.licenseKey.activate.useMutation();
const { mutateAsync: validateLicenseKey, isLoading: isValidating } =
api.licenseKey.validate.useMutation();
const { mutateAsync: deactivateLicenseKey, isLoading: isDeactivating } =
api.licenseKey.deactivate.useMutation();
const { data: haveValidLicenseKey, isLoading: isCheckingLicenseKey } =
api.licenseKey.haveValidLicenseKey.useQuery();
const [licenseKey, setLicenseKey] = useState("");
useEffect(() => {
if (data?.licenseKey) {
setLicenseKey(data.licenseKey);
}
}, [data?.licenseKey]);
const enabled = !!data?.enableEnterpriseFeatures;
return (
<div className="flex flex-col gap-4 rounded-lg border p-4">
{isCheckingLicenseKey ? (
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
<Loader2 className="size-6 text-muted-foreground animate-spin" />
<span className="text-sm text-muted-foreground">
Checking license key...
</span>
</div>
) : (
<>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Key className="size-6 text-muted-foreground" />
<CardTitle className="text-xl">License Key</CardTitle>
</div>
{enabled && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{enabled ? "Enabled" : "Disabled"}
</span>
<Switch
checked={enabled}
disabled={isLoading || isSaving || isDeactivating}
onCheckedChange={async (next) => {
try {
await updateEnterpriseSettings({
enableEnterpriseFeatures: next,
});
await utils.licenseKey.getEnterpriseSettings.invalidate();
toast.success("Enterprise features updated");
} catch (error) {
console.error(error);
toast.error("Failed to update enterprise features");
}
}}
/>
</div>
)}
</div>
<p className="text-sm text-muted-foreground">
To unlock extra features you need an enterprise license key.
Contact us{" "}
<Link
href="https://dokploy.com/contact"
target="_blank"
rel="noreferrer"
className="underline underline-offset-4"
>
here
</Link>
.
</p>
</div>
{enabled ? (
<>
<div className="grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="licenseKey">
License Key
</label>
<Input
id="licenseKey"
placeholder="Enter your enterprise license key"
value={licenseKey}
onChange={(e) => setLicenseKey(e.target.value)}
/>
</div>
<div className="md:justify-self-end flex gap-2">
{haveValidLicenseKey && (
<DialogAction
title="Deactivate License Key"
description="Are you sure you want to deactivate this license key? This will disable enterprise features."
onClick={async () => {
try {
await deactivateLicenseKey();
await utils.licenseKey.getEnterpriseSettings.invalidate();
await utils.licenseKey.haveValidLicenseKey.invalidate();
setLicenseKey("");
toast.success("License key deactivated");
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Failed to deactivate license key",
);
}
}}
disabled={isDeactivating || !haveValidLicenseKey}
>
<Button
variant="destructive"
disabled={isDeactivating || !haveValidLicenseKey}
isLoading={isDeactivating}
>
Deactivate
</Button>
</DialogAction>
)}
{haveValidLicenseKey && (
<Button
variant="outline"
disabled={
isSaving || isCheckingLicenseKey || isDeactivating
}
isLoading={isValidating}
onClick={async () => {
try {
const valid = await validateLicenseKey();
if (valid) {
toast.success("License key is valid");
} else {
toast.error("License key is invalid");
}
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Failed to validate license key",
);
}
}}
>
Validate
</Button>
)}
{!haveValidLicenseKey && (
<Button
variant="secondary"
disabled={isSaving || isValidating || isDeactivating}
isLoading={isActivating}
onClick={async () => {
try {
await activateLicenseKey({ licenseKey });
await utils.licenseKey.getEnterpriseSettings.invalidate();
await utils.licenseKey.haveValidLicenseKey.invalidate();
toast.success("License key activated");
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Failed to activate license key",
);
}
}}
>
Activate
</Button>
)}
</div>
</div>
</>
) : (
<div className="flex flex-col items-center gap-4 justify-center min-h-[30vh] text-center">
<div className="flex flex-col items-center gap-2 max-w-[400px]">
<div className="rounded-full bg-muted p-4">
<ShieldCheck className="size-8 text-muted-foreground" />
</div>
<div className="space-y-1">
<h3 className="text-lg font-semibold">Enterprise Features</h3>
<p className="text-sm text-muted-foreground">
Unlock advanced capabilities like SSO, Audit logs,
whitelabeling and more.
</p>
</div>
</div>
<Button
onClick={async () => {
try {
await updateEnterpriseSettings({
enableEnterpriseFeatures: true,
});
await utils.licenseKey.getEnterpriseSettings.invalidate();
toast.success("Enterprise features enabled");
} catch (error) {
console.error(error);
toast.error("Failed to enable enterprise features");
}
}}
isLoading={isSaving}
disabled={isLoading || isDeactivating}
>
Enable Enterprise Features
</Button>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,352 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import type { FieldArrayPath } from "react-hook-form";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const DEFAULT_SCOPES = ["openid", "email", "profile"];
const domainsArraySchema = z
.array(z.string().trim())
.superRefine((arr, ctx) => {
const filled = arr.filter((s) => s.length > 0);
if (filled.length < 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "At least one domain is required",
path: [],
});
}
});
const scopesArraySchema = z.array(z.string().trim());
const oidcProviderSchema = z.object({
providerId: z.string().min(1, "Provider ID is required").trim(),
issuer: z.string().min(1, "Issuer URL is required").url("Invalid URL").trim(),
domains: domainsArraySchema,
clientId: z.string().min(1, "Client ID is required").trim(),
clientSecret: z.string().min(1, "Client secret is required"),
scopes: scopesArraySchema,
});
type OidcProviderForm = z.infer<typeof oidcProviderSchema>;
interface RegisterOidcDialogProps {
children: React.ReactNode;
}
const formDefaultValues = {
providerId: "",
issuer: "",
domains: [""],
clientId: "",
clientSecret: "",
scopes: [...DEFAULT_SCOPES],
};
export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { mutateAsync, isLoading } = api.sso.register.useMutation();
const form = useForm<OidcProviderForm>({
resolver: zodResolver(oidcProviderSchema),
defaultValues: formDefaultValues,
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "domains" as FieldArrayPath<OidcProviderForm>,
});
const {
fields: scopeFields,
append: appendScope,
remove: removeScope,
} = useFieldArray({
control: form.control,
name: "scopes" as FieldArrayPath<OidcProviderForm>,
});
const isSubmitting = form.formState.isSubmitting;
const onSubmit = async (data: OidcProviderForm) => {
try {
const scopes = data.scopes.filter(Boolean).length
? data.scopes.filter(Boolean)
: DEFAULT_SCOPES;
const isAzure = data.issuer.includes("login.microsoftonline.com");
const mapping = isAzure
? {
id: "sub",
email: "preferred_username",
emailVerified: "email_verified",
name: "name",
}
: {
id: "sub",
email: "email",
emailVerified: "email_verified",
name: "preferred_username",
image: "picture",
};
await mutateAsync({
providerId: data.providerId,
issuer: data.issuer,
domains: data.domains,
oidcConfig: {
clientId: data.clientId,
clientSecret: data.clientSecret,
scopes,
pkce: true,
mapping,
},
});
toast.success("OIDC provider registered successfully");
form.reset(formDefaultValues);
setOpen(false);
await utils.sso.listProviders.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to register SSO provider",
);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Register OIDC provider</DialogTitle>
<DialogDescription>
Add any OIDC-compliant identity provider (e.g. Okta, Azure AD,
Google Workspace, Auth0, Keycloak). Discovery will fill endpoints
from the issuer URL when possible.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="providerId"
render={({ field }) => (
<FormItem>
<FormLabel>Provider ID</FormLabel>
<FormControl>
<Input placeholder="e.g. okta or my-idp" {...field} />
</FormControl>
<FormDescription>
Unique identifier; used in callback URL path.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="issuer"
render={({ field }) => (
<FormItem>
<FormLabel>Issuer URL</FormLabel>
<FormControl>
<Input placeholder="https://idp.example.com" {...field} />
</FormControl>
<FormDescription>
Discovery document is fetched from{" "}
<code className="rounded bg-muted px-1">
{"{issuer}"}/.well-known/openid-configuration
</code>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel>Domains</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={() => (append as (value: string) => void)("")}
>
<Plus className="mr-1 size-4" />
Add domain
</Button>
</div>
<p className="text-xs text-muted-foreground">
Email domains that use this provider (sign-in by email and org
assignment; subdomains matched automatically).
</p>
{fields.map((field, index) => (
<FormField
key={field.id}
control={form.control}
name={`domains.${index}`}
render={({ field: inputField }) => (
<FormItem>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="company.com"
className="flex-1"
{...inputField}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => remove(index)}
disabled={fields.length <= 1}
>
<Trash2 className="size-4" />
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
{(() => {
const err = form.formState.errors.domains;
const msg =
typeof err?.message === "string"
? err.message
: (err as { root?: { message?: string } } | undefined)?.root
?.message;
return msg ? (
<p className="text-sm font-medium text-destructive">{msg}</p>
) : null;
})()}
</div>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>Client ID</FormLabel>
<FormControl>
<Input placeholder="Client ID from IdP" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>Client secret</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Client secret from IdP"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel>Scopes (optional)</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={() => (appendScope as (value: string) => void)("")}
>
<Plus className="mr-1 size-4" />
Add scope
</Button>
</div>
<FormDescription>
OIDC scopes to request (e.g. openid, email, profile). If empty,
openid, email and profile are used.
</FormDescription>
{scopeFields.map((field, index) => (
<FormField
key={field.id}
control={form.control}
name={`scopes.${index}`}
render={({ field: inputField }) => (
<FormItem>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="openid"
className="flex-1"
{...inputField}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeScope(index)}
disabled={scopeFields.length <= 1}
>
<Trash2 className="size-4" />
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" isLoading={isLoading}>
Register provider
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,328 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { type FieldArrayPath, useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
const domainsArraySchema = z
.array(z.string().trim())
.superRefine((arr, ctx) => {
const filled = arr.filter((s) => s.length > 0);
if (filled.length < 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "At least one domain is required",
path: [],
});
}
});
const samlProviderSchema = z.object({
providerId: z.string().min(1, "Provider ID is required").trim(),
issuer: z.string().min(1, "Issuer URL is required").url("Invalid URL").trim(),
domains: domainsArraySchema,
entryPoint: z
.string()
.min(1, "IdP SSO URL is required")
.url("Invalid URL")
.trim(),
cert: z.string().min(1, "IdP signing certificate is required"),
idpMetadataXml: z.string().optional(),
});
type SamlProviderForm = z.infer<typeof samlProviderSchema>;
interface RegisterSamlDialogProps {
children: React.ReactNode;
}
const formDefaultValues: SamlProviderForm = {
providerId: "",
issuer: "",
domains: [""],
entryPoint: "",
cert: "",
idpMetadataXml: "",
};
export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { mutateAsync, isLoading } = api.sso.register.useMutation();
const [baseURL, setBaseURL] = useState("");
useEffect(() => {
if (typeof window !== "undefined") {
setBaseURL(window.location.origin);
}
}, []);
const form = useForm<SamlProviderForm>({
resolver: zodResolver(samlProviderSchema),
defaultValues: formDefaultValues,
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "domains" as FieldArrayPath<SamlProviderForm>,
});
const isSubmitting = form.formState.isSubmitting;
const onSubmit = async (data: SamlProviderForm) => {
try {
// maybe add the /saml/metadata endpoint to the baseURL
const baseURLWithMetadata = `${baseURL}/saml/metadata`;
const generateSpMetadata = (providerId: string) => {
return `<?xml version="1.0" encoding="UTF-8"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${baseURL}">
<md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${baseURL}/api/auth/sso/saml2/callback/${providerId}" index="1"/>
</md:SPSSODescriptor>
</md:EntityDescriptor>`;
};
await mutateAsync({
providerId: data.providerId,
issuer: data.issuer,
domains: data.domains,
samlConfig: {
entryPoint: data.entryPoint,
cert: data.cert,
callbackUrl: `${baseURL}/api/auth/sso/saml2/callback/${data.providerId}`,
audience: baseURL,
idpMetadata: data.idpMetadataXml?.trim()
? { metadata: data.idpMetadataXml.trim() }
: undefined,
spMetadata: {
metadata: generateSpMetadata(data.providerId),
},
mapping: {
id: "nameID",
email: "email",
name: "displayName",
firstName: "givenName",
lastName: "surname",
},
},
});
toast.success("SAML provider registered successfully");
form.reset(formDefaultValues);
setOpen(false);
await utils.sso.listProviders.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to register SAML provider",
);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Register SAML provider</DialogTitle>
<DialogDescription>
Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML,
OneLogin). You need the IdP&apos;s SSO URL and signing certificate.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="providerId"
render={({ field }) => (
<FormItem>
<FormLabel>Provider ID</FormLabel>
<FormControl>
<Input
placeholder="e.g. okta-saml or azure-saml"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="issuer"
render={({ field }) => (
<FormItem>
<FormLabel>Issuer URL</FormLabel>
<FormControl>
<Input placeholder="https://idp.example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel>Domains</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={() => append("")}
>
<Plus className="mr-1 size-4" />
Add domain
</Button>
</div>
<FormDescription>
Email domains that use this provider (sign-in by email and org
assignment; subdomains matched automatically).
</FormDescription>
{fields.map((field, index) => (
<FormField
key={field.id}
control={form.control}
name={`domains.${index}`}
render={({ field: inputField }) => (
<FormItem>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="company.com"
className="flex-1"
{...inputField}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => remove(index)}
disabled={fields.length <= 1}
>
<Trash2 className="size-4" />
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
{(() => {
const err = form.formState.errors.domains;
const msg =
typeof err?.message === "string"
? err.message
: (err as { root?: { message?: string } } | undefined)?.root
?.message;
return msg ? (
<p className="text-sm font-medium text-destructive">{msg}</p>
) : null;
})()}
</div>
<FormField
control={form.control}
name="entryPoint"
render={({ field }) => (
<FormItem>
<FormLabel>IdP SSO URL (Entry point)</FormLabel>
<FormControl>
<Input
placeholder="https://idp.example.com/sso"
{...field}
/>
</FormControl>
<FormDescription>
Single Sign-On URL from your IdP&apos;s SAML setup.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cert"
render={({ field }) => (
<FormItem>
<FormLabel>IdP signing certificate (X.509)</FormLabel>
<FormControl>
<Textarea
placeholder="Paste IdP signing certificate (PEM, BEGIN CERTIFICATE / END CERTIFICATE)"
rows={4}
className="font-mono text-xs"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="idpMetadataXml"
render={({ field }) => (
<FormItem>
<FormLabel>IdP metadata XML (optional)</FormLabel>
<FormControl>
<Textarea
placeholder="Paste full IdP metadata XML if you have it (EntityDescriptor). Otherwise leave empty and use Issuer, IdP SSO URL and certificate above."
rows={5}
className="font-mono text-xs"
{...field}
/>
</FormControl>
<FormDescription>
Some IdPs require full metadata; paste the XML here to
override issuer/entry point/cert.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" isLoading={isLoading}>
Register provider
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, LogIn } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
const ssoEmailSchema = z.object({
email: z
.string()
.min(1, "Enter your work email")
.email("Enter a valid email address")
.transform((v) => v.trim()),
});
type SSOEmailForm = z.infer<typeof ssoEmailSchema>;
interface SignInWithSSOProps {
/** Content shown when SSO is collapsed (e.g. email/password form) */
children: React.ReactNode;
}
export function SignInWithSSO({ children }: SignInWithSSOProps) {
const [expanded, setExpanded] = useState(false);
const form = useForm<SSOEmailForm>({
resolver: zodResolver(ssoEmailSchema),
defaultValues: { email: "" },
});
const onSubmit = async (values: SSOEmailForm) => {
try {
const { data, error } = await authClient.signIn.sso({
email: values.email,
callbackURL: "/dashboard/projects",
});
if (error) {
toast.error(error.message ?? "Failed to sign in with SSO");
return;
}
if (data?.url) {
window.location.href = data.url;
}
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to sign in with SSO",
);
}
};
if (!expanded) {
return (
<div className="mb-4 space-y-2">
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => setExpanded(true)}
>
<LogIn className="mr-2 size-4" />
Sign in with SSO
</Button>
{children}
</div>
);
}
return (
<div className="mb-4 space-y-2">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex gap-2">
<Input
type="email"
placeholder="you@company.com"
className="flex-1"
autoComplete="email"
disabled={form.formState.isSubmitting}
{...field}
/>
<Button
type="submit"
variant="outline"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Continue"
)}
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<button
type="button"
onClick={() => setExpanded(false)}
className="text-xs text-muted-foreground hover:underline"
>
Use email and password instead
</button>
</form>
</Form>
</div>
);
}

View File

@@ -0,0 +1,371 @@
"use client";
import { Eye, Loader2, LogIn, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import { RegisterOidcDialog } from "./register-oidc-dialog";
import { RegisterSamlDialog } from "./register-saml-dialog";
type ProviderForDetails = {
id: string | null;
providerId: string;
issuer: string;
domain: string;
oidcConfig: string | null;
samlConfig: string | null;
organizationId: string | null;
};
function parseOidcConfig(config: string | null): {
clientId?: string;
scopes?: string[];
} | null {
if (!config) return null;
try {
const parsed = JSON.parse(config) as {
clientId?: string;
scopes?: string[];
};
return { clientId: parsed.clientId, scopes: parsed.scopes };
} catch {
return null;
}
}
function parseSamlConfig(
config: string | null,
): { entryPoint?: string } | null {
if (!config) return null;
try {
const parsed = JSON.parse(config) as { entryPoint?: string };
return { entryPoint: parsed.entryPoint };
} catch {
return null;
}
}
export const SSOSettings = () => {
const utils = api.useUtils();
const [detailsProvider, setDetailsProvider] =
useState<ProviderForDetails | null>(null);
const [baseURL, setBaseURL] = useState("");
useEffect(() => {
if (typeof window !== "undefined") {
setBaseURL(window.location.origin);
}
}, []);
const { data: providers, isLoading } = api.sso.listProviders.useQuery();
const { mutateAsync: deleteProvider, isLoading: isDeleting } =
api.sso.deleteProvider.useMutation();
return (
<div className="flex flex-col gap-4 rounded-lg border p-4">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<LogIn className="size-6 text-muted-foreground" />
<CardTitle className="text-xl">Single Sign-On (SSO)</CardTitle>
</div>
<CardDescription>
Configure OIDC or SAML identity providers for enterprise sign-in.
Users can sign in with their organization&apos;s IdP.
</CardDescription>
</div>
{isLoading ? (
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
<Loader2 className="size-6 text-muted-foreground animate-spin" />
<span className="text-sm text-muted-foreground">
Loading providers...
</span>
</div>
) : (
<>
{providers && providers.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<RegisterOidcDialog>
<Button variant="secondary" size="sm">
<LogIn className="mr-2 size-4" />
Add OIDC provider
</Button>
</RegisterOidcDialog>
<RegisterSamlDialog>
<Button variant="secondary" size="sm">
<LogIn className="mr-2 size-4" />
Add SAML provider
</Button>
</RegisterSamlDialog>
</div>
)}
{providers && providers.length > 0 ? (
<div className="space-y-3">
<span className="text-sm font-medium">Registered providers</span>
<div className="grid gap-3 sm:grid-cols-2">
{providers.map((provider) => {
const isOidc = !!provider.oidcConfig;
const isSaml = !!provider.samlConfig;
return (
<Card
key={provider.id}
className="overflow-hidden bg-background"
>
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<CardTitle className="text-base font-medium">
{provider.providerId}
</CardTitle>
<CardDescription className="text-xs">
{provider.issuer}
</CardDescription>
<div className="flex flex-wrap gap-1 mt-1">
<Badge variant="secondary" className="text-xs">
{provider.domain}
</Badge>
{isOidc && (
<Badge variant="outline" className="text-xs">
OIDC
</Badge>
)}
{isSaml && (
<Badge variant="outline" className="text-xs">
SAML
</Badge>
)}
</div>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-wrap gap-2 pt-0">
<Button
variant="ghost"
size="sm"
onClick={() =>
setDetailsProvider({
id: provider.id,
providerId: provider.providerId,
issuer: provider.issuer,
domain: provider.domain,
oidcConfig: provider.oidcConfig,
samlConfig: provider.samlConfig,
organizationId: provider.organizationId,
})
}
>
<Eye className="mr-1 size-3" />
View details
</Button>
<DialogAction
title="Remove SSO provider"
description={`Remove provider "${provider.providerId}"? Users will no longer be able to sign in with this IdP.`}
type="destructive"
onClick={async () => {
try {
await deleteProvider({
providerId: provider.providerId,
});
toast.success("Provider removed");
await utils.sso.listProviders.invalidate();
} catch (err) {
toast.error(
err instanceof Error
? err.message
: "Failed to remove provider",
);
}
}}
>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={isDeleting}
>
<Trash2 className="mr-1 size-3" />
Remove
</Button>
</DialogAction>
</CardContent>
</Card>
);
})}
</div>
</div>
) : (
<div className="flex flex-col items-center gap-4 justify-center min-h-[30vh] text-center">
<div className="flex flex-col items-center gap-2 max-w-[400px]">
<div className="rounded-full bg-muted p-4">
<LogIn className="size-8 text-muted-foreground" />
</div>
<div className="space-y-1">
<h3 className="text-lg font-semibold">No SSO providers</h3>
<p className="text-sm text-muted-foreground">
Add an OIDC or SAML provider so users can sign in with their
organization&apos;s IdP (e.g. Okta, Azure AD).
</p>
</div>
</div>
<div className="flex flex-wrap gap-2 justify-center">
<RegisterOidcDialog>
<Button variant="secondary">
<LogIn className="mr-2 size-4" />
Add OIDC provider
</Button>
</RegisterOidcDialog>
<RegisterSamlDialog>
<Button variant="outline">
<LogIn className="mr-2 size-4" />
Add SAML provider
</Button>
</RegisterSamlDialog>
</div>
</div>
)}
</>
)}
<Dialog
open={!!detailsProvider}
onOpenChange={(open) => !open && setDetailsProvider(null)}
>
<DialogContent className="sm:max-w-[480px]">
{detailsProvider && (
<>
<DialogHeader>
<DialogTitle>SSO provider details</DialogTitle>
<DialogDescription>
View-only. To change settings, remove this provider and add it
again with the new values.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-2">
<div className="grid gap-1">
<span className="text-xs font-medium text-muted-foreground">
Provider ID
</span>
<p className="rounded-md bg-muted px-2 py-1.5 font-mono text-sm">
{detailsProvider.providerId}
</p>
</div>
<div className="grid gap-1">
<span className="text-xs font-medium text-muted-foreground">
Issuer URL
</span>
<p className="break-all rounded-md bg-muted px-2 py-1.5 text-sm">
{detailsProvider.issuer}
</p>
</div>
<div className="grid gap-1">
<span className="text-xs font-medium text-muted-foreground">
Domain
</span>
<p className="rounded-md bg-muted px-2 py-1.5 text-sm">
{detailsProvider.domain}
</p>
</div>
{detailsProvider.oidcConfig && (
<>
{(() => {
const oidc = parseOidcConfig(detailsProvider.oidcConfig);
if (!oidc) return null;
return (
<>
{oidc.clientId && (
<div className="grid gap-1">
<span className="text-xs font-medium text-muted-foreground">
Client ID
</span>
<p className="rounded-md bg-muted px-2 py-1.5 font-mono text-sm">
{oidc.clientId}
</p>
</div>
)}
{oidc.scopes && oidc.scopes.length > 0 && (
<div className="grid gap-1">
<span className="text-xs font-medium text-muted-foreground">
Scopes
</span>
<p className="rounded-md bg-muted px-2 py-1.5 text-sm">
{oidc.scopes.join(" ")}
</p>
</div>
)}
</>
);
})()}
</>
)}
{detailsProvider.samlConfig && (
<>
{(() => {
const saml = parseSamlConfig(detailsProvider.samlConfig);
if (!saml?.entryPoint) return null;
return (
<div className="grid gap-1">
<span className="text-xs font-medium text-muted-foreground">
Entry point
</span>
<p className="break-all rounded-md bg-muted px-2 py-1.5 text-sm">
{saml.entryPoint}
</p>
</div>
);
})()}
</>
)}
<div className="grid gap-1">
<span className="text-xs font-medium text-muted-foreground">
Callback URL (configure in your IdP)
</span>
<p className="break-all rounded-md bg-muted px-2 py-1.5 font-mono text-xs">
{baseURL || "{baseURL}"}
{detailsProvider.samlConfig
? "/api/auth/sso/saml2/callback/"
: "/api/auth/sso/callback/"}
{detailsProvider.providerId}
</p>
{!baseURL && (
<p className="text-xs text-muted-foreground">
Replace {"{baseURL}"} with your Dokploy URL (e.g. https://
your-domain.com).
</p>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDetailsProvider(null)}
>
Close
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
</div>
);
};

View File

@@ -10,7 +10,7 @@ export const ToggleVisibilityInput = ({ ...props }: InputProps) => {
return (
<div className="flex w-full items-center space-x-2">
<Input ref={inputRef} type={"password"} {...props} />
<Input ref={inputRef} {...props} type="password" />
<Button
variant={"secondary"}
onClick={() => {

View File

@@ -1,18 +1,75 @@
import { EyeIcon, EyeOffIcon } from "lucide-react";
import { EyeIcon, EyeOffIcon, RefreshCcw } from "lucide-react";
import * as React from "react";
import { generateRandomPassword } from "@/lib/password-utils";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
errorMessage?: string;
enablePasswordGenerator?: boolean;
passwordGeneratorLength?: number;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, errorMessage, type, ...props }, ref) => {
(
{
className,
errorMessage,
type,
enablePasswordGenerator = false,
passwordGeneratorLength,
...props
},
ref,
) => {
const [showPassword, setShowPassword] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const isPassword = type === "password";
const shouldShowGenerator =
isPassword &&
enablePasswordGenerator !== false &&
!props.disabled &&
!props.readOnly;
const inputType = isPassword ? (showPassword ? "text" : "password") : type;
const setRefs = React.useCallback(
(node: HTMLInputElement | null) => {
// @ts-ignore
inputRef.current = node;
if (typeof ref === "function") {
ref(node);
} else if (ref) {
ref.current = node;
}
},
[ref],
);
const handleGeneratePassword = () => {
const nextValue =
typeof passwordGeneratorLength === "number" &&
passwordGeneratorLength > 0
? generateRandomPassword(Math.floor(passwordGeneratorLength))
: generateRandomPassword();
const input = inputRef.current;
if (!input) {
return;
}
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
if (valueSetter) {
valueSetter.call(input, nextValue);
} else {
input.value = nextValue;
}
input.dispatchEvent(new Event("input", { bubbles: true }));
};
return (
<>
<div className="relative w-full">
@@ -21,25 +78,39 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
className={cn(
// bg-gray
"flex h-10 w-full rounded-md bg-input px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border disabled:cursor-not-allowed disabled:opacity-50",
isPassword && "pr-10", // Add padding for the eye icon
isPassword && (shouldShowGenerator ? "pr-16" : "pr-10"),
className,
)}
ref={ref}
ref={setRefs}
{...props}
/>
{isPassword && (
<button
type="button"
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground focus:outline-none"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
{showPassword ? (
<EyeOffIcon className="h-4 w-4" />
) : (
<EyeIcon className="h-4 w-4" />
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-3 text-muted-foreground">
{shouldShowGenerator && (
<button
type="button"
className="hover:text-foreground focus:outline-none"
onClick={handleGeneratePassword}
aria-label="Generate password"
title="Generate password"
tabIndex={-1}
>
<RefreshCcw className="h-4 w-4" />
</button>
)}
</button>
<button
type="button"
className="hover:text-foreground focus:outline-none"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
{showPassword ? (
<EyeOffIcon className="h-4 w-4" />
) : (
<EyeIcon className="h-4 w-4" />
)}
</button>
</div>
)}
</div>
{errorMessage && (