Merge branch 'canary' into feat/ntfy

This commit is contained in:
Mauricio Siu
2025-08-28 19:07:53 -06:00
32 changed files with 7246 additions and 313 deletions

View File

@@ -27,6 +27,7 @@ if (typeof window === "undefined") {
const baseApp: ApplicationNested = {
railpackVersion: "0.2.2",
applicationId: "",
previewLabels: [],
herokuVersion: "",
giteaBranch: "",
giteaBuildPath: "",

View File

@@ -177,3 +177,77 @@ COMPLEX_VAR="'Prefix \"DoubleQuoted\" and \${{project.APP_NAME}}'"
]);
});
});
describe("prepareEnvironmentVariables (self references)", () => {
it("resolves self references correctly", () => {
const serviceEnv = `
ENVIRONMENT=staging
DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db
SELF_REF=\${{ENVIRONMENT}}
`;
const resolved = prepareEnvironmentVariables(serviceEnv, "");
expect(resolved).toEqual([
"ENVIRONMENT=staging",
"DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db",
"SELF_REF=staging",
]);
});
it("throws on undefined self references", () => {
const serviceEnv = `
MISSING_VAR=\${{UNDEFINED_VAR}}
`;
expect(() => prepareEnvironmentVariables(serviceEnv, "")).toThrow(
"Invalid service environment variable: UNDEFINED_VAR",
);
});
it("allows overriding and still resolving from self", () => {
const serviceEnv = `
ENVIRONMENT=production
OVERRIDE_ENV=\${{ENVIRONMENT}}
`;
const resolved = prepareEnvironmentVariables(serviceEnv, "");
expect(resolved).toEqual([
"ENVIRONMENT=production",
"OVERRIDE_ENV=production",
]);
});
it("resolves multiple self references inside one value", () => {
const serviceEnv = `
ENVIRONMENT=staging
APP_NAME=MyApp
COMPLEX=\${{APP_NAME}}-\${{ENVIRONMENT}}-\${{APP_NAME}}
`;
const resolved = prepareEnvironmentVariables(serviceEnv, "");
expect(resolved).toEqual([
"ENVIRONMENT=staging",
"APP_NAME=MyApp",
"COMPLEX=MyApp-staging-MyApp",
]);
});
it("handles quotes with self references", () => {
const serviceEnv = `
ENVIRONMENT=production
QUOTED="'\${{ENVIRONMENT}}'"
MIXED="\"Double \${{ENVIRONMENT}}\""
`;
const resolved = prepareEnvironmentVariables(serviceEnv, "");
expect(resolved).toEqual([
"ENVIRONMENT=production",
"QUOTED='production'",
'MIXED="Double production"',
]);
});
});

View File

@@ -6,6 +6,7 @@ const baseApp: ApplicationNested = {
railpackVersion: "0.2.2",
rollbackActive: false,
applicationId: "",
previewLabels: [],
herokuVersion: "",
giteaRepository: "",
giteaOwner: "",

View File

@@ -1,9 +1,10 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Settings2 } from "lucide-react";
import { HelpCircle, Plus, Settings2, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -33,6 +34,12 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
const schema = z
@@ -42,6 +49,7 @@ const schema = z
wildcardDomain: z.string(),
port: z.number(),
previewLimit: z.number(),
previewLabels: z.array(z.string()).optional(),
previewHttps: z.boolean(),
previewPath: z.string(),
previewCertificateType: z.enum(["letsencrypt", "none", "custom"]),
@@ -81,6 +89,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
wildcardDomain: "*.traefik.me",
port: 3000,
previewLimit: 3,
previewLabels: [],
previewHttps: false,
previewPath: "/",
previewCertificateType: "none",
@@ -102,6 +111,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
buildArgs: data.previewBuildArgs || "",
wildcardDomain: data.previewWildcard || "*.traefik.me",
port: data.previewPort || 3000,
previewLabels: data.previewLabels || [],
previewLimit: data.previewLimit || 3,
previewHttps: data.previewHttps || false,
previewPath: data.previewPath || "/",
@@ -119,6 +129,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewBuildArgs: formData.buildArgs,
previewWildcard: formData.wildcardDomain,
previewPort: formData.port,
previewLabels: formData.previewLabels,
applicationId,
previewLimit: formData.previewLimit,
previewHttps: formData.previewHttps,
@@ -200,6 +211,90 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="previewLabels"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Preview Labels</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
<p>
Add a labels that will trigger a preview
deployment for a pull request. If no labels
are specified, all pull requests will trigger
a preview deployment.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((label, index) => (
<Badge
key={index}
variant="secondary"
className="flex items-center gap-1"
>
{label}
<X
className="size-3 cursor-pointer hover:text-destructive"
onClick={() => {
const newLabels = [...(field.value || [])];
newLabels.splice(index, 1);
field.onChange(newLabels);
}}
/>
</Badge>
))}
</div>
<div className="flex gap-2">
<FormControl>
<Input
placeholder="Enter a label (e.g. enhancements, needs-review)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const label = input.value.trim();
if (label) {
field.onChange([
...(field.value || []),
label,
]);
input.value = "";
}
}
}}
/>
</FormControl>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
const input = document.querySelector(
'input[placeholder*="Enter a label"]',
) as HTMLInputElement;
const label = input.value.trim();
if (label) {
field.onChange([...(field.value || []), label]);
input.value = "";
}
}}
>
<Plus className="size-4" />
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="previewLimit"

View File

@@ -55,7 +55,12 @@ const formSchema = z
cronExpression: z.string().min(1, "Cron expression is required"),
volumeName: z.string().min(1, "Volume name is required"),
prefix: z.string(),
// keepLatestCount: z.coerce.number().optional(),
keepLatestCount: z.coerce
.number()
.int()
.gte(1, "Must be at least 1")
.optional()
.nullable(),
turnOff: z.boolean().default(false),
enabled: z.boolean().default(true),
serviceType: z.enum([
@@ -108,6 +113,7 @@ export const HandleVolumeBackups = ({
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [cacheType, setCacheType] = useState<CacheType>("cache");
const [keepLatestCountInput, setKeepLatestCountInput] = useState("");
const utils = api.useUtils();
const form = useForm<z.infer<typeof formSchema>>({
@@ -117,7 +123,7 @@ export const HandleVolumeBackups = ({
cronExpression: "",
volumeName: "",
prefix: "",
// keepLatestCount: undefined,
keepLatestCount: undefined,
turnOff: false,
enabled: true,
serviceName: "",
@@ -173,13 +179,19 @@ export const HandleVolumeBackups = ({
cronExpression: volumeBackup.cronExpression,
volumeName: volumeBackup.volumeName || "",
prefix: volumeBackup.prefix,
// keepLatestCount: volumeBackup.keepLatestCount || undefined,
keepLatestCount: volumeBackup.keepLatestCount || undefined,
turnOff: volumeBackup.turnOff,
enabled: volumeBackup.enabled || false,
serviceName: volumeBackup.serviceName || "",
destinationId: volumeBackup.destinationId,
serviceType: volumeBackup.serviceType,
});
setKeepLatestCountInput(
volumeBackup.keepLatestCount !== null &&
volumeBackup.keepLatestCount !== undefined
? String(volumeBackup.keepLatestCount)
: "",
);
}
}, [form, volumeBackup, volumeBackupId]);
@@ -190,8 +202,12 @@ export const HandleVolumeBackups = ({
const onSubmit = async (values: z.infer<typeof formSchema>) => {
if (!id && !volumeBackupId) return;
const preparedKeepLatestCount =
keepLatestCountInput === "" ? null : (values.keepLatestCount ?? null);
await mutateAsync({
...values,
keepLatestCount: preparedKeepLatestCount,
destinationId: values.destinationId,
volumeBackupId: volumeBackupId || "",
serviceType: volumeBackupType,
@@ -257,9 +273,8 @@ export const HandleVolumeBackups = ({
</DialogTrigger>
<DialogContent
className={cn(
"overflow-y-auto",
volumeBackupType === "compose" || volumeBackupType === "application"
? "max-h-[95vh] sm:max-w-2xl"
? "sm:max-w-2xl"
: " sm:max-w-lg",
)}
>
@@ -600,29 +615,38 @@ export const HandleVolumeBackups = ({
)}
/>
{/* <FormField
<FormField
control={form.control}
name="keepLatestCount"
render={({ field }) => (
<FormItem>
<FormLabel>Keep Latest Count</FormLabel>
<FormLabel>Keep Latest Backups</FormLabel>
<FormControl>
<Input
type="number"
placeholder="5"
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value) || undefined)
}
type="number"
min={1}
autoComplete="off"
placeholder="Leave empty to keep all"
value={keepLatestCountInput}
onChange={(e) => {
const raw = e.target.value;
setKeepLatestCountInput(raw);
if (raw === "") {
field.onChange(undefined);
} else if (/^\d+$/.test(raw)) {
field.onChange(Number(raw));
}
}}
/>
</FormControl>
<FormDescription>
Number of backup files to keep (optional)
How many recent backups to keep. Empty means no cleanup.
</FormDescription>
<FormMessage />
</FormItem>
)}
/> */}
/>
<FormField
control={form.control}

View File

@@ -7,6 +7,7 @@ import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -114,6 +115,12 @@ export const DeleteService = ({ id, type }: Props) => {
}
};
const isDisabled =
(data &&
"applicationStatus" in data &&
data?.applicationStatus === "running") ||
(data && "composeStatus" in data && data?.composeStatus === "running");
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
@@ -202,6 +209,12 @@ export const DeleteService = ({ id, type }: Props) => {
</form>
</Form>
</div>
{isDisabled && (
<AlertBlock type="warning" className="w-full mt-5">
Cannot delete the service while it is running. Please wait for the
build to finish and then try again.
</AlertBlock>
)}
<DialogFooter>
<Button
variant="secondary"
@@ -211,8 +224,10 @@ export const DeleteService = ({ id, type }: Props) => {
>
Cancel
</Button>
<Button
isLoading={isLoading}
disabled={isDisabled}
form="hook-form-delete-compose"
type="submit"
variant="destructive"

View File

@@ -24,6 +24,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
const organizationSchema = z.object({
@@ -54,6 +55,8 @@ export function AddOrganization({ organizationId }: Props) {
const { mutateAsync, isLoading } = organizationId
? api.organization.update.useMutation()
: api.organization.create.useMutation();
const { refetch: refetchActiveOrganization } =
authClient.useActiveOrganization();
const form = useForm<OrganizationFormValues>({
resolver: zodResolver(organizationSchema),
@@ -84,6 +87,10 @@ export function AddOrganization({ organizationId }: Props) {
`Organization ${organizationId ? "updated" : "created"} successfully`,
);
utils.organization.all.invalidate();
if (organizationId) {
utils.organization.one.invalidate({ organizationId });
refetchActiveOrganization();
}
setOpen(false);
})
.catch((error) => {

View File

@@ -38,7 +38,7 @@ import { api } from "@/utils/api";
const Schema = z.object({
name: z.string().min(1, { message: "Name is required" }),
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
apiKey: z.string().min(1, { message: "API Key is required" }),
apiKey: z.string(),
model: z.string().min(1, { message: "Model is required" }),
isEnabled: z.boolean(),
});
@@ -71,7 +71,7 @@ export const HandleAi = ({ aiId }: Props) => {
name: "",
apiUrl: "",
apiKey: "",
model: "gpt-3.5-turbo",
model: "",
isEnabled: true,
},
});
@@ -81,7 +81,7 @@ export const HandleAi = ({ aiId }: Props) => {
name: data?.name ?? "",
apiUrl: data?.apiUrl ?? "https://api.openai.com/v1",
apiKey: data?.apiKey ?? "",
model: data?.model ?? "gpt-3.5-turbo",
model: data?.model ?? "",
isEnabled: data?.isEnabled ?? true,
});
}, [aiId, form, data]);
@@ -89,6 +89,7 @@ export const HandleAi = ({ aiId }: Props) => {
const apiUrl = form.watch("apiUrl");
const apiKey = form.watch("apiKey");
const isOllama = apiUrl.includes(":11434") || apiUrl.includes("ollama");
const { data: models, isLoading: isLoadingServerModels } =
api.ai.getModels.useQuery(
{
@@ -96,7 +97,7 @@ export const HandleAi = ({ aiId }: Props) => {
apiKey: apiKey ?? "",
},
{
enabled: !!apiUrl && !!apiKey,
enabled: !!apiUrl && (isOllama || !!apiKey),
onError: (error) => {
setError(`Failed to fetch models: ${error.message}`);
},
@@ -191,22 +192,29 @@ export const HandleAi = ({ aiId }: Props) => {
)}
/>
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>API Key</FormLabel>
<FormControl>
<Input type="password" placeholder="sk-..." {...field} />
</FormControl>
<FormDescription>
Your API key for authentication
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{!isOllama && (
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder="sk-..."
autoComplete="one-time-code"
{...field}
/>
</FormControl>
<FormDescription>
Your API key for authentication
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{isLoadingServerModels && (
<span className="text-sm text-muted-foreground">
@@ -214,6 +222,12 @@ export const HandleAi = ({ aiId }: Props) => {
</span>
)}
{!isLoadingServerModels && !models?.length && (
<span className="text-sm text-muted-foreground">
No models available
</span>
)}
{!isLoadingServerModels && models && models.length > 0 && (
<FormField
control={form.control}

View File

@@ -11,11 +11,20 @@ interface Props {
export const DashboardLayout = ({ children }: Props) => {
const { data: haveRootAccess } = api.user.haveRootAccess.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: isUserSubscribed } = api.settings.isUserSubscribed.useQuery(
undefined,
{
enabled: isCloud === true,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
},
);
return (
<>
<Page>{children}</Page>
{isCloud === true && (
{isCloud === true && isUserSubscribed === true && (
<ChatwootWidget websiteToken="USCpQRKzHvFMssf3p6Eacae5" />
)}

View File

@@ -129,10 +129,11 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 w-full max-w-lg translate-x-[-50%] translate-y-[-50%] border bg-background shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 pointer-events-auto w-full max-w-lg translate-x-[-50%] translate-y-[-50%] border bg-background shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"flex flex-col max-h-[90vh]",
className,
)}
style={{ pointerEvents: "auto" }}
onInteractOutside={(event) => event.preventDefault()}
{...props}
>

View File

@@ -148,7 +148,7 @@ const SidebarProvider = React.forwardRef<
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
"group/sidebar-wrapper flex h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className,
)}
ref={ref}
@@ -329,10 +329,11 @@ const SidebarInset = React.forwardRef<
<main
ref={ref}
className={cn(
"relative flex min-h-svh overflow-auto w-full flex-col bg-background",
"relative flex min-h-svh overflow-auto w-full flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className,
)}
style={{ scrollbarGutter: "stable" }}
{...props}
/>
);

View File

@@ -0,0 +1 @@
ALTER TABLE "application" ADD COLUMN "previewLabels" text[];

File diff suppressed because it is too large Load Diff

View File

@@ -743,6 +743,13 @@
"when": 1754259281559,
"tag": "0105_clumsy_quicksilver",
"breakpoints": true
},
{
"idx": 106,
"version": "7",
"when": 1754912062243,
"tag": "0106_purple_maggott",
"breakpoints": true
}
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.24.11",
"version": "v0.25.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -37,13 +37,13 @@
"test": "vitest --config __test__/vitest.config.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^1.2.12",
"@ai-sdk/azure": "^1.3.23",
"@ai-sdk/cohere": "^1.2.10",
"@ai-sdk/deepinfra": "^0.0.4",
"@ai-sdk/mistral": "^1.2.8",
"@ai-sdk/openai": "^1.3.22",
"@ai-sdk/openai-compatible": "^0.0.13",
"@ai-sdk/anthropic": "^2.0.5",
"@ai-sdk/azure": "^2.0.16",
"@ai-sdk/cohere": "^2.0.4",
"@ai-sdk/deepinfra": "^1.0.10",
"@ai-sdk/mistral": "^2.0.7",
"@ai-sdk/openai": "^2.0.16",
"@ai-sdk/openai-compatible": "^1.0.10",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.2",
@@ -91,7 +91,8 @@
"@xterm/addon-clipboard": "0.1.0",
"@xterm/xterm": "^5.5.0",
"adm-zip": "^0.5.16",
"ai": "^4.3.16",
"ai": "^5.0.17",
"ai-sdk-ollama": "^0.5.1",
"bcrypt": "5.1.1",
"better-auth": "v1.2.8-beta.7",
"bl": "6.0.11",
@@ -124,7 +125,6 @@
"node-schedule": "2.1.1",
"nodemailer": "6.9.14",
"octokit": "3.1.2",
"ollama-ai-provider": "^1.2.0",
"otpauth": "^9.4.0",
"pino": "9.4.0",
"pino-pretty": "11.2.2",

View File

@@ -343,7 +343,9 @@ export default async function handler(
if (
action === "opened" ||
action === "synchronize" ||
action === "reopened"
action === "reopened" ||
action === "labeled" ||
action === "unlabeled"
) {
const repository = githubBody?.repository?.name;
const deploymentHash = githubBody?.pull_request?.head?.sha;
@@ -442,6 +444,19 @@ export default async function handler(
}
for (const app of secureApps) {
// check for labels
if (app?.previewLabels && app?.previewLabels?.length > 0) {
let hasLabel = false;
const labels = githubBody?.pull_request?.labels;
for (const label of labels) {
if (app?.previewLabels?.includes(label.name)) {
hasLabel = true;
break;
}
}
if (!hasLabel) continue;
}
const previewLimit = app?.previewLimit || 0;
if (app?.previewDeployments?.length > previewLimit) {
continue;

View File

@@ -10,6 +10,7 @@ import {
FolderInput,
GlobeIcon,
Loader2,
Play,
PlusIcon,
Search,
ServerIcon,
@@ -40,10 +41,12 @@ import {
RedisIcon,
} from "@/components/icons/data-tools-icons";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AlertBlock } from "@/components/shared/alert-block";
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -288,6 +291,8 @@ const Project = (
const [openCombobox, setOpenCombobox] = useState(false);
const [selectedServices, setSelectedServices] = useState<string[]>([]);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [deleteVolumes, setDeleteVolumes] = useState(false);
const handleSelectAll = () => {
if (selectedServices.length === filteredServices.length) {
@@ -311,6 +316,7 @@ const Project = (
stop: api.compose.stop.useMutation(),
move: api.compose.move.useMutation(),
delete: api.compose.delete.useMutation(),
deploy: api.compose.deploy.useMutation(),
};
const applicationActions = {
@@ -318,6 +324,7 @@ const Project = (
stop: api.application.stop.useMutation(),
move: api.application.move.useMutation(),
delete: api.application.delete.useMutation(),
deploy: api.application.deploy.useMutation(),
};
const postgresActions = {
@@ -325,6 +332,7 @@ const Project = (
stop: api.postgres.stop.useMutation(),
move: api.postgres.move.useMutation(),
delete: api.postgres.remove.useMutation(),
deploy: api.postgres.deploy.useMutation(),
};
const mysqlActions = {
@@ -332,6 +340,7 @@ const Project = (
stop: api.mysql.stop.useMutation(),
move: api.mysql.move.useMutation(),
delete: api.mysql.remove.useMutation(),
deploy: api.mysql.deploy.useMutation(),
};
const mariadbActions = {
@@ -339,6 +348,7 @@ const Project = (
stop: api.mariadb.stop.useMutation(),
move: api.mariadb.move.useMutation(),
delete: api.mariadb.remove.useMutation(),
deploy: api.mariadb.deploy.useMutation(),
};
const redisActions = {
@@ -346,6 +356,7 @@ const Project = (
stop: api.redis.stop.useMutation(),
move: api.redis.move.useMutation(),
delete: api.redis.remove.useMutation(),
deploy: api.redis.deploy.useMutation(),
};
const mongoActions = {
@@ -353,6 +364,7 @@ const Project = (
stop: api.mongo.stop.useMutation(),
move: api.mongo.move.useMutation(),
delete: api.mongo.remove.useMutation(),
deploy: api.mongo.deploy.useMutation(),
};
const handleBulkStart = async () => {
@@ -523,7 +535,7 @@ const Project = (
setIsBulkActionLoading(false);
};
const handleBulkDelete = async () => {
const handleBulkDelete = async (deleteVolumes = false) => {
let success = 0;
setIsBulkActionLoading(true);
for (const serviceId of selectedServices) {
@@ -540,7 +552,7 @@ const Project = (
case "compose":
await composeActions.delete.mutateAsync({
composeId: serviceId,
deleteVolumes: false,
deleteVolumes,
});
break;
case "postgres":
@@ -585,6 +597,83 @@ const Project = (
setIsBulkActionLoading(false);
};
const handleBulkDeploy = async () => {
let success = 0;
let failed = 0;
setIsBulkActionLoading(true);
for (const serviceId of selectedServices) {
try {
const service = filteredServices.find((s) => s.id === serviceId);
if (!service) continue;
switch (service.type) {
case "application":
await applicationActions.deploy.mutateAsync({
applicationId: serviceId,
});
break;
case "compose":
await composeActions.deploy.mutateAsync({
composeId: serviceId,
});
break;
case "postgres":
await postgresActions.deploy.mutateAsync({
postgresId: serviceId,
});
break;
case "mysql":
await mysqlActions.deploy.mutateAsync({
mysqlId: serviceId,
});
break;
case "mariadb":
await mariadbActions.deploy.mutateAsync({
mariadbId: serviceId,
});
break;
case "redis":
await redisActions.deploy.mutateAsync({
redisId: serviceId,
});
break;
case "mongo":
await mongoActions.deploy.mutateAsync({
mongoId: serviceId,
});
break;
}
success++;
} catch (error) {
failed++;
toast.error(
`Error deploying service ${serviceId}: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
if (success > 0) {
toast.success(
`${success} service${success !== 1 ? "s" : ""} deployed successfully`,
);
}
if (failed > 0) {
toast.error(
`${failed} service${failed !== 1 ? "s" : ""} failed to deploy`,
);
}
setSelectedServices([]);
setIsDropdownOpen(false);
setIsBulkActionLoading(false);
};
const filteredServices = useMemo(() => {
if (!applications) return [];
const filtered = applications.filter(
@@ -598,6 +687,13 @@ const Project = (
return sortServices(filtered);
}, [applications, searchQuery, selectedTypes, sortBy]);
const selectedServicesWithRunningStatus = useMemo(() => {
return filteredServices.filter(
(service) =>
selectedServices.includes(service.id) && service.status === "running",
);
}, [filteredServices, selectedServices]);
return (
<div>
<BreadcrumbSidebar
@@ -721,6 +817,24 @@ const Project = (
Start
</Button>
</DialogAction>
<DialogAction
title="Deploy Services"
description={`Are you sure you want to deploy ${selectedServices.length} service${selectedServices.length !== 1 ? "s" : ""}? This will redeploy/restart the selected services.`}
onClick={handleBulkDeploy}
type="default"
disabled={
selectedServices.length === 0 ||
isBulkActionLoading
}
>
<Button
variant="ghost"
className="w-full justify-start"
>
<Play className="mr-2 h-4 w-4" />
Deploy
</Button>
</DialogAction>
<DialogAction
title="Stop Services"
description={`Are you sure you want to stop ${selectedServices.length} services?`}
@@ -740,9 +854,35 @@ const Project = (
<>
<DialogAction
title="Delete Services"
description={`Are you sure you want to delete ${selectedServices.length} services? This action cannot be undone.`}
description={
<div className="space-y-3">
<p>
Are you sure you want to delete{" "}
{selectedServices.length} services? This
action cannot be undone.
</p>
{selectedServicesWithRunningStatus.length >
0 && (
<AlertBlock type="warning">
Warning:{" "}
{
selectedServicesWithRunningStatus.length
}{" "}
of the selected services are currently
running. Please stop these services
first before deleting:{" "}
{selectedServicesWithRunningStatus
.map((s) => s.name)
.join(", ")}
</AlertBlock>
)}
</div>
}
type="destructive"
onClick={handleBulkDelete}
disabled={
selectedServicesWithRunningStatus.length > 0
}
onClick={() => setIsBulkDeleteDialogOpen(true)}
>
<Button
variant="ghost"
@@ -838,6 +978,113 @@ const Project = (
</DialogFooter>
</DialogContent>
</Dialog>
{/* Bulk Delete Dialog */}
<Dialog
open={isBulkDeleteDialogOpen}
onOpenChange={setIsBulkDeleteDialogOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Services</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
{selectedServices.length} service
{selectedServices.length !== 1 ? "s" : ""}?
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Show services to be deleted */}
<div className="max-h-40 overflow-y-auto space-y-2">
{selectedServices.map((serviceId) => {
const service = filteredServices.find(
(s) => s.id === serviceId,
);
return service ? (
<div
key={serviceId}
className="flex items-center space-x-2 text-sm"
>
<span className="px-2 py-1 text-xs bg-secondary rounded">
{service.type}
</span>
<span>{service.name}</span>
</div>
) : null;
})}
</div>
{/* Volume deletion option for compose services */}
{(() => {
const servicesWithVolumeSupport =
selectedServices.filter((serviceId) => {
const service = filteredServices.find(
(s) => s.id === serviceId,
);
// Currently only compose services support volume deletion
return service?.type === "compose";
});
if (servicesWithVolumeSupport.length === 0)
return null;
return (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="deleteVolumes"
checked={deleteVolumes}
onCheckedChange={(checked) =>
setDeleteVolumes(checked === true)
}
/>
<label
htmlFor="deleteVolumes"
className="text-sm font-medium"
>
Delete volumes associated with
services
</label>
</div>
<p className="text-xs text-muted-foreground">
Volume deletion is available for:{" "}
{servicesWithVolumeSupport.length}{" "}
compose service
{servicesWithVolumeSupport.length !== 1
? "s"
: ""}
</p>
</div>
);
})()}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsBulkDeleteDialogOpen(false);
setDeleteVolumes(false); // Reset checkbox
}}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
handleBulkDelete(deleteVolumes);
setIsBulkDeleteDialogOpen(false);
setDeleteVolumes(false); // Reset checkbox
}}
disabled={isBulkActionLoading}
>
Delete Services
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@@ -329,6 +329,7 @@ export default function Home({ IS_CLOUD }: Props) {
maxLength={6}
pattern={REGEXP_ONLY_DIGITS}
autoComplete="off"
autoFocus
>
<InputOTPGroup>
<InputOTPSlot index={0} className="border-border" />

View File

@@ -20,6 +20,7 @@ import {
} from "@dokploy/server/services/user";
import {
getProviderHeaders,
getProviderName,
type Model,
} from "@dokploy/server/utils/ai/select-ai-provider";
import { TRPCError } from "@trpc/server";
@@ -47,11 +48,24 @@ export const aiRouter = createTRPCRouter({
}),
getModels: protectedProcedure
.input(z.object({ apiUrl: z.string().min(1), apiKey: z.string().min(1) }))
.input(z.object({ apiUrl: z.string().min(1), apiKey: z.string() }))
.query(async ({ input }) => {
try {
const providerName = getProviderName(input.apiUrl);
const headers = getProviderHeaders(input.apiUrl, input.apiKey);
const response = await fetch(`${input.apiUrl}/models`, { headers });
let response = null;
switch (providerName) {
case "ollama":
response = await fetch(`${input.apiUrl}/api/tags`, { headers });
break;
default:
if (!input.apiKey)
throw new TRPCError({
code: "BAD_REQUEST",
message: "API key must contain at least 1 character(s)",
});
response = await fetch(`${input.apiUrl}/models`, { headers });
}
if (!response.ok) {
const errorText = await response.text();

View File

@@ -80,7 +80,7 @@ export const redisRouter = createTRPCRouter({
type: "volume",
});
return true;
return newRedis;
} catch (error) {
throw error;
}

View File

@@ -45,7 +45,7 @@ import {
} from "@dokploy/server";
import { generateOpenApiDocument } from "@dokploy/trpc-openapi";
import { TRPCError } from "@trpc/server";
import { sql } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { dump, load } from "js-yaml";
import { scheduledJobs, scheduleJob } from "node-schedule";
import { z } from "zod";
@@ -60,6 +60,8 @@ import {
apiServerSchema,
apiTraefikConfig,
apiUpdateDockerCleanup,
projects,
server,
} from "@/server/db/schema";
import { removeJob, schedule } from "@/server/utils/backup";
import packageInfo from "../../../package.json";
@@ -706,6 +708,18 @@ export const settingsRouter = createTRPCRouter({
isCloud: publicProcedure.query(async () => {
return IS_CLOUD;
}),
isUserSubscribed: protectedProcedure.query(async ({ ctx }) => {
const haveServers = await db.query.server.findMany({
where: eq(server.organizationId, ctx.session?.activeOrganizationId || ""),
});
const haveProjects = await db.query.projects.findMany({
where: eq(
projects.organizationId,
ctx.session?.activeOrganizationId || "",
),
});
return haveServers.length > 0 || haveProjects.length > 0;
}),
health: publicProcedure.query(async () => {
if (IS_CLOUD) {
try {

View File

@@ -21,7 +21,7 @@ import {
await initializeNetwork();
createDefaultTraefikConfig();
createDefaultServerTraefikConfig();
await execAsync("docker pull traefik:v3.1.2");
await execAsync("docker pull traefik:v3.5.0");
await initializeStandaloneTraefik();
await initializeRedis();
await initializePostgres();