Merge branch 'canary' into fix/environment-access-fallback

This commit is contained in:
Mauricio Siu
2026-01-21 11:09:02 +01:00
75 changed files with 10590 additions and 1898 deletions

View File

@@ -13,7 +13,6 @@
"@dokploy/server": "workspace:*",
"@hono/node-server": "^1.14.3",
"@hono/zod-validator": "0.3.0",
"@nerimity/mimiqueue": "1.2.3",
"dotenv": "^16.4.5",
"hono": "^4.7.10",
"pino": "9.4.0",

View File

@@ -25,7 +25,7 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
titleLog: z.string().optional(),
descriptionLog: z.string().optional(),
server: z.boolean().optional(),
type: z.enum(["deploy"]),
type: z.enum(["deploy", "redeploy"]),
applicationType: z.literal("application-preview"),
serverId: z.string().min(1),
}),

View File

@@ -4,6 +4,7 @@ import {
deployPreviewApplication,
rebuildApplication,
rebuildCompose,
rebuildPreviewApplication,
updateApplicationStatus,
updateCompose,
updatePreviewDeployment,
@@ -54,7 +55,14 @@ export const deploy = async (job: DeployJob) => {
previewStatus: "running",
});
if (job.server) {
if (job.type === "deploy") {
if (job.type === "redeploy") {
await rebuildPreviewApplication({
applicationId: job.applicationId,
titleLog: job.titleLog || "Rebuild Preview Deployment",
descriptionLog: job.descriptionLog || "",
previewDeploymentId: job.previewDeploymentId,
});
} else if (job.type === "deploy") {
await deployPreviewApplication({
applicationId: job.applicationId,
titleLog: job.titleLog || "Preview Deployment",

View File

@@ -25,7 +25,7 @@ if (typeof window === "undefined") {
}
const baseApp: ApplicationNested = {
railpackVersion: "0.2.2",
railpackVersion: "0.15.4",
applicationId: "",
previewLabels: [],
createEnvFile: true,

View File

@@ -0,0 +1,184 @@
import { getEnviromentVariablesObject } from "@dokploy/server/index";
import { describe, expect, it } from "vitest";
const projectEnv = `
ENVIRONMENT=staging
DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db
PORT=3000
`;
const environmentEnv = `
NODE_ENV=development
API_URL=https://api.dev.example.com
REDIS_URL=redis://localhost:6379
DATABASE_NAME=dev_database
SECRET_KEY=env-secret-123
`;
describe("getEnviromentVariablesObject with environment variables (Stack compose)", () => {
it("resolves environment variables correctly for Stack compose", () => {
const serviceEnv = `
FOO=\${{environment.NODE_ENV}}
BAR=\${{environment.API_URL}}
BAZ=test
`;
const result = getEnviromentVariablesObject(
serviceEnv,
projectEnv,
environmentEnv,
);
expect(result).toEqual({
FOO: "development",
BAR: "https://api.dev.example.com",
BAZ: "test",
});
});
it("resolves both project and environment variables for Stack compose", () => {
const serviceEnv = `
ENVIRONMENT=\${{project.ENVIRONMENT}}
NODE_ENV=\${{environment.NODE_ENV}}
API_URL=\${{environment.API_URL}}
DATABASE_URL=\${{project.DATABASE_URL}}
SERVICE_PORT=4000
`;
const result = getEnviromentVariablesObject(
serviceEnv,
projectEnv,
environmentEnv,
);
expect(result).toEqual({
ENVIRONMENT: "staging",
NODE_ENV: "development",
API_URL: "https://api.dev.example.com",
DATABASE_URL: "postgres://postgres:postgres@localhost:5432/project_db",
SERVICE_PORT: "4000",
});
});
it("handles multiple environment references in single value for Stack compose", () => {
const multiRefEnv = `
HOST=localhost
PORT=5432
USERNAME=postgres
PASSWORD=secret123
`;
const serviceEnv = `
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
`;
const result = getEnviromentVariablesObject(serviceEnv, "", multiRefEnv);
expect(result).toEqual({
DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
});
});
it("throws error for undefined environment variables in Stack compose", () => {
const serviceWithUndefined = `
UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}}
`;
expect(() =>
getEnviromentVariablesObject(serviceWithUndefined, "", environmentEnv),
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
});
it("allows service variables to override environment variables in Stack compose", () => {
const serviceOverrideEnv = `
NODE_ENV=production
API_URL=\${{environment.API_URL}}
`;
const result = getEnviromentVariablesObject(
serviceOverrideEnv,
"",
environmentEnv,
);
expect(result).toEqual({
NODE_ENV: "production",
API_URL: "https://api.dev.example.com",
});
});
it("resolves complex references with project, environment, and service variables for Stack compose", () => {
const complexServiceEnv = `
FULL_DATABASE_URL=\${{project.DATABASE_URL}}/\${{environment.DATABASE_NAME}}
API_ENDPOINT=\${{environment.API_URL}}/\${{project.ENVIRONMENT}}/api
SERVICE_NAME=my-service
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
`;
const result = getEnviromentVariablesObject(
complexServiceEnv,
projectEnv,
environmentEnv,
);
expect(result).toEqual({
FULL_DATABASE_URL:
"postgres://postgres:postgres@localhost:5432/project_db/dev_database",
API_ENDPOINT: "https://api.dev.example.com/staging/api",
SERVICE_NAME: "my-service",
COMPLEX_VAR: "my-service-development-staging",
});
});
it("maintains precedence: service > environment > project in Stack compose", () => {
const conflictingProjectEnv = `
NODE_ENV=production-project
API_URL=https://project.api.com
DATABASE_NAME=project_db
`;
const conflictingEnvironmentEnv = `
NODE_ENV=development-environment
API_URL=https://environment.api.com
DATABASE_NAME=env_db
`;
const serviceWithConflicts = `
NODE_ENV=service-override
PROJECT_ENV=\${{project.NODE_ENV}}
ENV_VAR=\${{environment.API_URL}}
DB_NAME=\${{environment.DATABASE_NAME}}
`;
const result = getEnviromentVariablesObject(
serviceWithConflicts,
conflictingProjectEnv,
conflictingEnvironmentEnv,
);
expect(result).toEqual({
NODE_ENV: "service-override",
PROJECT_ENV: "production-project",
ENV_VAR: "https://environment.api.com",
DB_NAME: "env_db",
});
});
it("handles empty environment variables in Stack compose", () => {
const serviceWithEmpty = `
SERVICE_VAR=test
PROJECT_VAR=\${{project.ENVIRONMENT}}
`;
const result = getEnviromentVariablesObject(
serviceWithEmpty,
projectEnv,
"",
);
expect(result).toEqual({
SERVICE_VAR: "test",
PROJECT_VAR: "staging",
});
});
});

View File

@@ -3,7 +3,7 @@ import { createRouterConfig } from "@dokploy/server";
import { expect, test } from "vitest";
const baseApp: ApplicationNested = {
railpackVersion: "0.2.2",
railpackVersion: "0.15.4",
rollbackActive: false,
applicationId: "",
previewLabels: [],

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,267 @@
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 [testCommands, setTestCommands] = useState<string[]>([]);
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,
},
});
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,
});
setTestCommands(hc.Test || []);
}
}, [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 = () => {
setTestCommands([...testCommands, ""]);
};
const updateTestCommand = (index: number, value: string) => {
const newCommands = [...testCommands];
newCommands[index] = value;
setTestCommands(newCommands);
};
const removeTestCommand = (index: number) => {
setTestCommands(testCommands.filter((_, i) => 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, index) => (
<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,10 @@
export { HealthCheckForm } from "./health-check-form";
export { RestartPolicyForm } from "./restart-policy-form";
export { PlacementForm } from "./placement-form";
export { UpdateConfigForm } from "./update-config-form";
export { RollbackConfigForm } from "./rollback-config-form";
export { ModeForm } from "./mode-form";
export { LabelsForm } from "./labels-form";
export { StopGracePeriodForm } from "./stop-grace-period-form";
export { EndpointSpecForm } from "./endpoint-spec-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,342 @@
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({
Spread: 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 : 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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -420,7 +420,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">
@@ -428,7 +428,7 @@ 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>
</span>

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

@@ -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

@@ -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 && (

View File

@@ -1,16 +0,0 @@
CREATE TABLE IF NOT EXISTS "ai" (
"aiId" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"apiUrl" text NOT NULL,
"apiKey" text NOT NULL,
"model" text NOT NULL,
"isEnabled" boolean DEFAULT true NOT NULL,
"adminId" text NOT NULL,
"createdAt" text NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "ai" ADD CONSTRAINT "ai_adminId_admin_adminId_fk" FOREIGN KEY ("adminId") REFERENCES "public"."admin"("adminId") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -0,0 +1 @@
ALTER TABLE "application" ALTER COLUMN "railpackVersion" SET DEFAULT '0.15.4';

File diff suppressed because it is too large Load Diff

View File

@@ -939,6 +939,13 @@
"when": 1766301478005,
"tag": "0133_striped_the_order",
"breakpoints": true
},
{
"idx": 134,
"version": "7",
"when": 1767871040249,
"tag": "0134_strong_hercules",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,38 @@
const DEFAULT_PASSWORD_LENGTH = 20;
const DEFAULT_PASSWORD_CHARSET =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
export const generateRandomPassword = (
length: number = DEFAULT_PASSWORD_LENGTH,
charset: string = DEFAULT_PASSWORD_CHARSET,
) => {
const safeLength =
Number.isFinite(length) && length > 0
? Math.floor(length)
: DEFAULT_PASSWORD_LENGTH;
if (safeLength <= 0 || charset.length === 0) {
return "";
}
const cryptoApi =
typeof globalThis !== "undefined" ? globalThis.crypto : undefined;
if (!cryptoApi?.getRandomValues) {
let fallback = "";
for (let i = 0; i < safeLength; i += 1) {
fallback += charset[Math.floor(Math.random() * charset.length)];
}
return fallback;
}
const values = new Uint32Array(safeLength);
cryptoApi.getRandomValues(values);
let result = "";
for (const value of values) {
result += charset[value % charset.length];
}
return result;
};

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.26.3",
"version": "v0.26.5",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -109,7 +109,6 @@
"drizzle-orm": "^0.39.3",
"drizzle-zod": "0.5.1",
"fancy-ansi": "^0.1.3",
"hi-base32": "^0.5.1",
"i18next": "^23.16.8",
"input-otp": "^1.4.2",
"js-cookie": "^3.0.5",
@@ -126,7 +125,6 @@
"node-schedule": "2.1.1",
"nodemailer": "6.9.14",
"octokit": "3.1.2",
"otpauth": "^9.4.0",
"pino": "9.4.0",
"pino-pretty": "11.2.2",
"postgres": "3.4.4",
@@ -155,9 +153,11 @@
"xterm-addon-fit": "^0.8.0",
"yaml": "2.8.1",
"zod": "^3.25.32",
"zod-form-data": "^2.0.7"
"zod-form-data": "^2.0.7",
"semver": "7.7.3"
},
"devDependencies": {
"@types/semver": "7.7.1",
"@types/shell-quote": "^1.7.5",
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "5.0.2",
@@ -196,10 +196,5 @@
"*": [
"biome check --write --no-errors-on-unmatched --files-ignore-unknown=true"
]
},
"commitlint": {
"extends": [
"@commitlint/config-conventional"
]
}
}

View File

@@ -909,7 +909,9 @@ const EnvironmentPage = (
<ProjectEnvironment projectId={projectId}>
<Button variant="outline">Project Environment</Button>
</ProjectEnvironment>
{(auth?.role === "owner" || auth?.canCreateServices) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canCreateServices) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>
@@ -1032,6 +1034,7 @@ const EnvironmentPage = (
</Button>
</DialogAction>
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<>
<DialogAction

View File

@@ -108,6 +108,7 @@ const Service = (
{ name: "Projects", href: "/dashboard/projects" },
{
name: data?.environment?.project?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
},
{
name: data?.environment?.name || "",
@@ -192,7 +193,9 @@ const Service = (
<div className="flex flex-row gap-2 justify-end">
<UpdateApplication applicationId={applicationId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<DeleteService id={applicationId} type="application" />
)}
</div>

View File

@@ -97,6 +97,7 @@ const Service = (
{ name: "Projects", href: "/dashboard/projects" },
{
name: data?.environment?.project?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
},
{
name: data?.environment?.name || "",
@@ -182,7 +183,9 @@ const Service = (
<div className="flex flex-row gap-2 justify-end">
<UpdateCompose composeId={composeId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<DeleteService id={composeId} type="compose" />
)}
</div>

View File

@@ -79,6 +79,7 @@ const Mariadb = (
{ name: "Projects", href: "/dashboard/projects" },
{
name: data?.environment?.project?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
},
{
name: data?.environment?.name || "",
@@ -156,7 +157,9 @@ const Mariadb = (
</div>
<div className="flex flex-row gap-2 justify-end">
<UpdateMariadb mariadbId={mariadbId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<DeleteService id={mariadbId} type="mariadb" />
)}
</div>

View File

@@ -78,6 +78,7 @@ const Mongo = (
{ name: "Projects", href: "/dashboard/projects" },
{
name: data?.environment?.project?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
},
{
name: data?.environment?.name || "",
@@ -155,7 +156,9 @@ const Mongo = (
<div className="flex flex-row gap-2 justify-end">
<UpdateMongo mongoId={mongoId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<DeleteService id={mongoId} type="mongo" />
)}
</div>

View File

@@ -77,6 +77,7 @@ const MySql = (
{ name: "Projects", href: "/dashboard/projects" },
{
name: data?.environment?.project?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
},
{
name: data?.environment?.name || "",
@@ -156,7 +157,9 @@ const MySql = (
<div className="flex flex-row gap-2 justify-end">
<UpdateMysql mysqlId={mysqlId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<DeleteService id={mysqlId} type="mysql" />
)}
</div>

View File

@@ -77,6 +77,7 @@ const Postgresql = (
{ name: "Projects", href: "/dashboard/projects" },
{
name: data?.environment?.project?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
},
{
name: data?.environment?.name || "",
@@ -154,7 +155,9 @@ const Postgresql = (
<div className="flex flex-row gap-2 justify-end">
<UpdatePostgres postgresId={postgresId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<DeleteService id={postgresId} type="postgres" />
)}
</div>

View File

@@ -77,6 +77,7 @@ const Redis = (
{ name: "Projects", href: "/dashboard/projects" },
{
name: data?.environment?.project?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
},
{
name: data?.environment?.name || "",
@@ -154,7 +155,9 @@ const Redis = (
<div className="flex flex-row gap-2 justify-end">
<UpdateRedis redisId={redisId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<DeleteService id={redisId} type="redis" />
)}
</div>

View File

@@ -0,0 +1,63 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowBillingInvoices } from "@/components/dashboard/settings/billing/show-billing-invoices";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
const Page = () => {
return <ShowBillingInvoices />;
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Invoices">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
if (!IS_CLOUD) {
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
},
};
}
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user || user.role !== "owner") {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
await helpers.settings.isCloud.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
},
};
}

View File

@@ -285,6 +285,7 @@ export const backupRouter = createTRPCRouter({
.mutation(async ({ input }) => {
const backup = await findBackupById(input.backupId);
await runWebServerBackup(backup);
await keepLatestNBackups(backup);
return true;
}),
listBackupFiles: protectedProcedure

View File

@@ -2,11 +2,15 @@ import {
findApplicationById,
findPreviewDeploymentById,
findPreviewDeploymentsByApplicationId,
IS_CLOUD,
removePreviewDeployment,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { apiFindAllByApplication } from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types";
import { myQueue } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const previewDeploymentRouter = createTRPCRouter({
@@ -60,4 +64,55 @@ export const previewDeploymentRouter = createTRPCRouter({
}
return previewDeployment;
}),
redeploy: protectedProcedure
.input(
z.object({
previewDeploymentId: z.string(),
title: z.string().optional(),
description: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
const previewDeployment = await findPreviewDeploymentById(
input.previewDeploymentId,
);
if (
previewDeployment.application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to redeploy this preview deployment",
});
}
const application = await findApplicationById(
previewDeployment.applicationId,
);
const jobData: DeploymentJob = {
applicationId: previewDeployment.applicationId,
titleLog: input.title || "Rebuild Preview Deployment",
descriptionLog: input.description || "",
type: "redeploy",
applicationType: "application-preview",
previewDeploymentId: input.previewDeploymentId,
server: !!application.serverId,
};
if (IS_CLOUD && application.serverId) {
jobData.serverId = application.serverId;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
return true;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
return true;
}),
});

View File

@@ -88,7 +88,7 @@ export const settingsRouter = createTRPCRouter({
if (IS_CLOUD) {
return true;
}
await reloadDockerResource("dokploy");
await reloadDockerResource("dokploy", undefined, packageInfo.version);
return true;
}),
cleanRedis: adminProcedure.mutation(async () => {
@@ -399,7 +399,7 @@ export const settingsRouter = createTRPCRouter({
return DEFAULT_UPDATE_DATA;
}
return await getUpdateData();
return await getUpdateData(packageInfo.version);
}),
updateServer: adminProcedure.mutation(async () => {
if (IS_CLOUD) {

View File

@@ -75,9 +75,9 @@ export const stripeRouter = createTRPCRouter({
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: items,
...(stripeCustomerId && {
customer: stripeCustomerId,
}),
...(stripeCustomerId
? { customer: stripeCustomerId }
: { customer_email: owner.email }),
metadata: {
adminId: owner.id,
},
@@ -128,4 +128,39 @@ export const stripeRouter = createTRPCRouter({
return servers.length < user.serversQuantity;
}),
getInvoices: adminProcedure.query(async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
const stripeCustomerId = user.stripeCustomerId;
if (!stripeCustomerId) {
return [];
}
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});
try {
const invoices = await stripe.invoices.list({
customer: stripeCustomerId,
limit: 100,
});
return invoices.data.map((invoice) => ({
id: invoice.id,
number: invoice.number,
status: invoice.status,
amountDue: invoice.amount_due,
amountPaid: invoice.amount_paid,
currency: invoice.currency,
created: invoice.created,
dueDate: invoice.due_date,
hostedInvoiceUrl: invoice.hosted_invoice_url,
invoicePdf: invoice.invoice_pdf,
}));
} catch (_) {
return [];
}
}),
});

View File

@@ -4,6 +4,7 @@ import {
deployPreviewApplication,
rebuildApplication,
rebuildCompose,
rebuildPreviewApplication,
updateApplicationStatus,
updateCompose,
updatePreviewDeployment,
@@ -54,7 +55,14 @@ export const deploymentWorker = new Worker(
previewStatus: "running",
});
if (job.data.type === "deploy") {
if (job.data.type === "redeploy") {
await rebuildPreviewApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
previewDeploymentId: job.data.previewDeploymentId,
});
} else if (job.data.type === "deploy") {
await deployPreviewApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,

View File

@@ -22,7 +22,7 @@ type DeployJob =
titleLog: string;
descriptionLog: string;
server?: boolean;
type: "deploy";
type: "deploy" | "redeploy";
applicationType: "application-preview";
previewDeploymentId: string;
serverId?: string;

View File

@@ -58,7 +58,7 @@ func (db *DB) GetLastNContainerMetrics(containerName string, limit int) ([]Conta
WITH recent_metrics AS (
SELECT metrics_json
FROM container_metrics
WHERE container_name LIKE ? || '%'
WHERE container_name = ?
ORDER BY timestamp DESC
LIMIT ?
)
@@ -98,7 +98,7 @@ func (db *DB) GetAllMetricsContainer(containerName string) ([]ContainerMetric, e
WITH recent_metrics AS (
SELECT metrics_json
FROM container_metrics
WHERE container_name LIKE ? || '%'
WHERE container_name = ?
ORDER BY timestamp DESC
)
SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC