Compare commits

...

26 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
f4ec77ade8 Optimize ubuntu image pull to only happen when image is missing
Co-authored-by: Siumauricio <47042324+Siumauricio@users.noreply.github.com>
2025-12-20 01:49:03 +00:00
copilot-swe-agent[bot]
5f441f5b54 Add pre-pull of ubuntu image before volume backup to prevent race condition with cleanup
Co-authored-by: Siumauricio <47042324+Siumauricio@users.noreply.github.com>
2025-12-20 01:47:10 +00:00
copilot-swe-agent[bot]
ed1e3244c6 Initial plan 2025-12-20 01:42:21 +00:00
Mauricio Siu
6685bd618e chore: update dokploy version to v0.26.3 and modify test command 2025-12-19 11:53:27 -06:00
Mauricio Siu
f5d334244a Merge pull request #3309 from Bima42/fix/3308-cannot-update-s3-endpoint
fix: invalidate query missing for s3 destination
2025-12-19 10:39:00 -06:00
Bima42
fd084c6d37 fix: invalidate query missing 2025-12-19 10:07:20 +01:00
Mauricio Siu
d8514b067b Merge pull request #3273 from ayham291/mongo-replica
fix(mongo): use appName instead of localhost for replica set
2025-12-18 00:26:29 -06:00
Mauricio Siu
0590e78854 Merge pull request #3270 from Bima42/3165-add-environment-switch-dropdown
feat: being able to switch environments from breadcrumbs
2025-12-18 00:20:00 -06:00
Mauricio Siu
27fa0e881a Merge pull request #3298 from Dokploy/3230-build-server---doesnt-use-registry
feat(registry): improve server selection by categorizing deploy and b…
2025-12-17 23:07:29 -06:00
Mauricio Siu
72f2cc6268 feat(registry): improve server selection by categorizing deploy and build servers
- Refactored server data handling to separate deploy and build servers.
- Updated the UI to display servers in distinct groups for better clarity.
- Enhanced the server selection experience by dynamically rendering server options based on availability.
2025-12-17 23:06:21 -06:00
Mauricio Siu
854bd88e0a Merge pull request #3292 from Dokploy/3261-the-registry-password-is-always-blank-when-you-modify-any-existing-registry
feat(registry): enhance registry handling with optional password and …
2025-12-16 22:09:24 -06:00
autofix-ci[bot]
acf385a1f3 [autofix.ci] apply automated fixes 2025-12-17 04:08:36 +00:00
Mauricio Siu
d1bc109697 feat(registry): enhance registry handling with optional password and new test functionality
- Updated the AddRegistrySchema to make the password field optional when editing an existing registry.
- Introduced a new mutation, testRegistryById, to validate registry credentials using existing data.
- Improved form handling to conditionally require the password based on the editing state.
- Enhanced user feedback for registry testing with clearer error messages and instructions.
2025-12-16 22:07:52 -06:00
Mauricio Siu
38c7e1e996 Merge pull request #3276 from Divkix/fix-3268
fix(api): return database object from create endpoints
2025-12-16 21:50:15 -06:00
Mauricio Siu
54d5266573 Merge pull request #3291 from Dokploy/feat/use-cards-in-remote-servers
Feat/use cards in remote servers
2025-12-16 21:14:26 -06:00
autofix-ci[bot]
3a5ac9d31f [autofix.ci] apply automated fixes 2025-12-17 03:09:23 +00:00
Mauricio Siu
0ddf6b851f feat(servers): add tooltip for deactivated server status in dashboard
- Wrapped server status display in a TooltipProvider to provide additional context for deactivated servers.
- Implemented a tooltip that informs users about the reason for deactivation and instructions for reactivation, enhancing user experience and clarity in server management.
2025-12-16 21:05:52 -06:00
Mauricio Siu
eb4fbff1b2 feat(servers): enhance server management UI with button options
- Added `asButton` prop to `HandleServers`, `SetupServer`, `ShowServerActions`, and `TerminalModal` components to allow rendering as buttons for improved UI flexibility.
- Updated the server management interface to use buttons for actions like editing and setting up servers, enhancing user experience.
- Introduced new icons for better visual representation of actions in the server management dashboard.
2025-12-15 15:17:56 -06:00
Bima42
3aeb52810c fix: missing switch env for apps 2025-12-15 10:10:12 +01:00
Divanshu Chauhan
8eaf2ab5c7 fix(api): return database object from create endpoints
Database creation APIs (mysql, mariadb, postgres, mongo) now return
the created database object with databaseID instead of boolean true.
This enables automation workflows to deploy databases immediately
after creation.

Fixes #3268
2025-12-15 11:56:39 +05:30
Mauricio Siu
5ebcbf86ea Merge pull request #3275 from Dokploy/3274-null-server-ip
fix(auth): update admin check to safely access user property
2025-12-15 00:24:03 -06:00
Mauricio Siu
67f4ca2cd9 fix(auth): update admin check to safely access user property
- Modified the admin check to use optional chaining, ensuring that the user property is accessed only if it exists, preventing potential runtime errors.
2025-12-15 00:23:43 -06:00
ayham291
6bb5404f87 fix(mongo): use appName instead of localhost for replica set
localhost doesn't work properly in containers
2025-12-15 00:05:38 +01:00
Bima42
3e356e6890 feat: being able to switch environments in sidebar 2025-12-14 17:01:44 +01:00
Mauricio Siu
72cc7a2d2c Merge pull request #3265 from Dokploy/feat/templates-processor-allow-empty-variables-references
test(helpers): add tests for handling empty and undefined string vari…
2025-12-13 23:12:10 -06:00
Mauricio Siu
d875e08d48 test(helpers): add tests for handling empty and undefined string variables in templates
- Introduced new test cases to verify the behavior of the `processValue` function when dealing with empty string variables and undefined variables.
- Ensured that empty strings are correctly replaced and undefined variables remain unchanged in the output.
2025-12-13 15:05:57 -06:00
28 changed files with 729 additions and 292 deletions

View File

@@ -161,6 +161,50 @@ describe("helpers functions", () => {
}); });
}); });
describe("Empty string variables", () => {
it("should replace variables with empty string values correctly", () => {
const variables = {
smtp_username: "",
smtp_password: "",
non_empty: "value",
};
const result1 = processValue("${smtp_username}", variables, mockSchema);
expect(result1).toBe("");
const result2 = processValue("${smtp_password}", variables, mockSchema);
expect(result2).toBe("");
const result3 = processValue("${non_empty}", variables, mockSchema);
expect(result3).toBe("value");
});
it("should not replace undefined variables", () => {
const variables = {
defined_var: "",
};
const result = processValue("${undefined_var}", variables, mockSchema);
expect(result).toBe("${undefined_var}");
});
it("should handle mixed empty and non-empty variables in template", () => {
const variables = {
smtp_address: "smtp.example.com",
smtp_port: "2525",
smtp_username: "",
smtp_password: "",
};
const template =
"SMTP_ADDRESS=${smtp_address} SMTP_PORT=${smtp_port} SMTP_USERNAME=${smtp_username} SMTP_PASSWORD=${smtp_password}";
const result = processValue(template, variables, mockSchema);
expect(result).toBe(
"SMTP_ADDRESS=smtp.example.com SMTP_PORT=2525 SMTP_USERNAME= SMTP_PASSWORD=",
);
});
});
describe("${jwt}", () => { describe("${jwt}", () => {
it("should generate a JWT string", () => { it("should generate a JWT string", () => {
const jwt = processValue("${jwt}", {}, mockSchema); const jwt = processValue("${jwt}", {}, mockSchema);

View File

@@ -42,9 +42,7 @@ const AddRegistrySchema = z.object({
username: z.string().min(1, { username: z.string().min(1, {
message: "Username is required", message: "Username is required",
}), }),
password: z.string().min(1, { password: z.string(),
message: "Password is required",
}),
registryUrl: z registryUrl: z
.string() .string()
.optional() .optional()
@@ -75,6 +73,7 @@ const AddRegistrySchema = z.object({
), ),
imagePrefix: z.string(), imagePrefix: z.string(),
serverId: z.string().optional(), serverId: z.string().optional(),
isEditing: z.boolean().optional(),
}); });
type AddRegistry = z.infer<typeof AddRegistrySchema>; type AddRegistry = z.infer<typeof AddRegistrySchema>;
@@ -101,13 +100,21 @@ export const HandleRegistry = ({ registryId }: Props) => {
const { mutateAsync, error, isError } = registryId const { mutateAsync, error, isError } = registryId
? api.registry.update.useMutation() ? api.registry.update.useMutation()
: api.registry.create.useMutation(); : api.registry.create.useMutation();
const { data: servers } = api.server.withSSHKey.useQuery(); const { data: deployServers } = api.server.withSSHKey.useQuery();
const { data: buildServers } = api.server.buildServers.useQuery();
const servers = [...(deployServers || []), ...(buildServers || [])];
const { const {
mutateAsync: testRegistry, mutateAsync: testRegistry,
isLoading, isLoading,
error: testRegistryError, error: testRegistryError,
isError: testRegistryIsError, isError: testRegistryIsError,
} = api.registry.testRegistry.useMutation(); } = api.registry.testRegistry.useMutation();
const {
mutateAsync: testRegistryById,
isLoading: isLoadingById,
error: testRegistryByIdError,
isError: testRegistryByIdIsError,
} = api.registry.testRegistryById.useMutation();
const form = useForm<AddRegistry>({ const form = useForm<AddRegistry>({
defaultValues: { defaultValues: {
username: "", username: "",
@@ -116,8 +123,26 @@ export const HandleRegistry = ({ registryId }: Props) => {
imagePrefix: "", imagePrefix: "",
registryName: "", registryName: "",
serverId: "", serverId: "",
isEditing: !!registryId,
}, },
resolver: zodResolver(AddRegistrySchema), resolver: zodResolver(
AddRegistrySchema.refine(
(data) => {
// When creating a new registry, password is required
if (
!data.isEditing &&
(!data.password || data.password.length === 0)
) {
return false;
}
return true;
},
{
message: "Password is required",
path: ["password"],
},
),
),
}); });
const password = form.watch("password"); const password = form.watch("password");
@@ -138,6 +163,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
registryUrl: registry.registryUrl, registryUrl: registry.registryUrl,
imagePrefix: registry.imagePrefix || "", imagePrefix: registry.imagePrefix || "",
registryName: registry.registryName, registryName: registry.registryName,
isEditing: true,
}); });
} else { } else {
form.reset({ form.reset({
@@ -146,13 +172,13 @@ export const HandleRegistry = ({ registryId }: Props) => {
registryUrl: "", registryUrl: "",
imagePrefix: "", imagePrefix: "",
serverId: "", serverId: "",
isEditing: false,
}); });
} }
}, [form, form.reset, form.formState.isSubmitSuccessful, registry]); }, [form, form.reset, form.formState.isSubmitSuccessful, registry]);
const onSubmit = async (data: AddRegistry) => { const onSubmit = async (data: AddRegistry) => {
await mutateAsync({ const payload: any = {
password: data.password,
registryName: data.registryName, registryName: data.registryName,
username: data.username, username: data.username,
registryUrl: data.registryUrl || "", registryUrl: data.registryUrl || "",
@@ -160,7 +186,15 @@ export const HandleRegistry = ({ registryId }: Props) => {
imagePrefix: data.imagePrefix, imagePrefix: data.imagePrefix,
serverId: data.serverId, serverId: data.serverId,
registryId: registryId || "", registryId: registryId || "",
}) };
// Only include password if it's been provided (not empty)
// When editing, empty password means "keep the existing password"
if (data.password && data.password.length > 0) {
payload.password = data.password;
}
await mutateAsync(payload)
.then(async (_data) => { .then(async (_data) => {
await utils.registry.all.invalidate(); await utils.registry.all.invalidate();
toast.success(registryId ? "Registry updated" : "Registry added"); toast.success(registryId ? "Registry updated" : "Registry added");
@@ -198,11 +232,14 @@ export const HandleRegistry = ({ registryId }: Props) => {
Fill the next fields to add a external registry. Fill the next fields to add a external registry.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{(isError || testRegistryIsError) && ( {(isError || testRegistryIsError || testRegistryByIdIsError) && (
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950"> <div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" /> <AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400"> <span className="text-sm text-red-600 dark:text-red-400">
{testRegistryError?.message || error?.message || ""} {testRegistryError?.message ||
testRegistryByIdError?.message ||
error?.message ||
""}
</span> </span>
</div> </div>
)} )}
@@ -253,10 +290,20 @@ export const HandleRegistry = ({ registryId }: Props) => {
name="password" name="password"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Password</FormLabel> <FormLabel>Password{registryId && " (Optional)"}</FormLabel>
{registryId && (
<FormDescription>
Leave blank to keep existing password. Enter new
password to test or update it.
</FormDescription>
)}
<FormControl> <FormControl>
<Input <Input
placeholder="Password" placeholder={
registryId
? "Leave blank to keep existing"
: "Password"
}
autoComplete="one-time-code" autoComplete="one-time-code"
{...field} {...field}
type="password" type="password"
@@ -360,16 +407,33 @@ export const HandleRegistry = ({ registryId }: Props) => {
<SelectValue placeholder="Select a server" /> <SelectValue placeholder="Select a server" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{deployServers && deployServers.length > 0 && (
<SelectGroup>
<SelectLabel>Deploy Servers</SelectLabel>
{deployServers.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
</SelectGroup>
)}
{buildServers && buildServers.length > 0 && (
<SelectGroup>
<SelectLabel>Build Servers</SelectLabel>
{buildServers.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
</SelectGroup>
)}
<SelectGroup> <SelectGroup>
<SelectLabel>Servers</SelectLabel>
{servers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
<SelectItem value={"none"}>None</SelectItem> <SelectItem value={"none"}>None</SelectItem>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
@@ -387,8 +451,37 @@ export const HandleRegistry = ({ registryId }: Props) => {
<Button <Button
type="button" type="button"
variant={"secondary"} variant={"secondary"}
isLoading={isLoading} isLoading={isLoading || isLoadingById}
onClick={async () => { onClick={async () => {
// When editing with empty password, use the existing password from DB
if (registryId && (!password || password.length === 0)) {
await testRegistryById({
registryId: registryId || "",
...(serverId && { serverId }),
})
.then((data) => {
if (data) {
toast.success("Registry Tested Successfully");
} else {
toast.error("Registry Test Failed");
}
})
.catch(() => {
toast.error("Error testing the registry");
});
return;
}
// When creating, password is required
if (!registryId && (!password || password.length === 0)) {
form.setError("password", {
type: "manual",
message: "Password is required",
});
return;
}
// When creating or editing with new password, validate and test with provided credentials
const validationResult = AddRegistrySchema.safeParse({ const validationResult = AddRegistrySchema.safeParse({
username, username,
password, password,
@@ -396,6 +489,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
registryName: "Dokploy Registry", registryName: "Dokploy Registry",
imagePrefix, imagePrefix,
serverId, serverId,
isEditing: !!registryId,
}); });
if (!validationResult.success) { if (!validationResult.success) {

View File

@@ -122,6 +122,9 @@ export const HandleDestinations = ({ destinationId }: Props) => {
.then(async () => { .then(async () => {
toast.success(`Destination ${destinationId ? "Updated" : "Created"}`); toast.success(`Destination ${destinationId ? "Updated" : "Created"}`);
await utils.destination.all.invalidate(); await utils.destination.all.invalidate();
if (destinationId) {
await utils.destination.one.invalidate({ destinationId });
}
setOpen(false); setOpen(false);
}) })
.catch(() => { .catch(() => {

View File

@@ -1,3 +1,4 @@
import { Activity } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { import {
Dialog, Dialog,
@@ -6,6 +7,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { ShowStorageActions } from "./show-storage-actions"; import { ShowStorageActions } from "./show-storage-actions";
import { ShowTraefikActions } from "./show-traefik-actions"; import { ShowTraefikActions } from "./show-traefik-actions";
@@ -13,20 +15,30 @@ import { ToggleDockerCleanup } from "./toggle-docker-cleanup";
interface Props { interface Props {
serverId: string; serverId: string;
asButton?: boolean;
} }
export const ShowServerActions = ({ serverId }: Props) => { export const ShowServerActions = ({ serverId, asButton = false }: Props) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> {asButton ? (
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Activity className="h-4 w-4" />
</Button>
</DialogTrigger>
) : (
<DropdownMenuItem <DropdownMenuItem
className="w-full cursor-pointer" className="w-full cursor-pointer"
onSelect={(e) => e.preventDefault()} onSelect={(e) => {
e.preventDefault();
setIsOpen(true);
}}
> >
View Actions View Actions
</DropdownMenuItem> </DropdownMenuItem>
</DialogTrigger> )}
<DialogContent className="sm:max-w-xl"> <DialogContent className="sm:max-w-xl">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<DialogTitle className="text-xl">Web server settings</DialogTitle> <DialogTitle className="text-xl">Web server settings</DialogTitle>

View File

@@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react"; import { PlusIcon, Pencil } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -59,9 +59,10 @@ type Schema = z.infer<typeof Schema>;
interface Props { interface Props {
serverId?: string; serverId?: string;
asButton?: boolean;
} }
export const HandleServers = ({ serverId }: Props) => { export const HandleServers = ({ serverId, asButton = false }: Props) => {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
const utils = api.useUtils(); const utils = api.useUtils();
@@ -137,21 +138,32 @@ export const HandleServers = ({ serverId }: Props) => {
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> {serverId ? (
{serverId ? ( asButton ? (
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Pencil className="h-4 w-4" />
</Button>
</DialogTrigger>
) : (
<DropdownMenuItem <DropdownMenuItem
className="w-full cursor-pointer " className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()} onSelect={(e) => {
e.preventDefault();
setIsOpen(true);
}}
> >
Edit Server Edit Server
</DropdownMenuItem> </DropdownMenuItem>
) : ( )
) : (
<DialogTrigger asChild>
<Button className="cursor-pointer space-x-3"> <Button className="cursor-pointer space-x-3">
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
Create Server Create Server
</Button> </Button>
)} </DialogTrigger>
</DialogTrigger> )}
<DialogContent className="sm:max-w-3xl "> <DialogContent className="sm:max-w-3xl ">
<DialogHeader> <DialogHeader>
<DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle> <DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle>

View File

@@ -1,5 +1,5 @@
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { CopyIcon, ExternalLinkIcon, ServerIcon } from "lucide-react"; import { CopyIcon, ExternalLinkIcon, ServerIcon, Settings } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -36,9 +36,10 @@ import { ValidateServer } from "./validate-server";
interface Props { interface Props {
serverId: string; serverId: string;
asButton?: boolean;
} }
export const SetupServer = ({ serverId }: Props) => { export const SetupServer = ({ serverId, asButton = false }: Props) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { data: server } = api.server.one.useQuery( const { data: server } = api.server.one.useQuery(
{ {
@@ -81,14 +82,23 @@ export const SetupServer = ({ serverId }: Props) => {
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> {asButton ? (
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Settings className="h-4 w-4" />
</Button>
</DialogTrigger>
) : (
<DropdownMenuItem <DropdownMenuItem
className="w-full cursor-pointer " className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()} onSelect={(e) => {
e.preventDefault();
setIsOpen(true);
}}
> >
Setup Server Setup Server
</DropdownMenuItem> </DropdownMenuItem>
</DialogTrigger> )}
<DialogContent className="sm:max-w-4xl "> <DialogContent className="sm:max-w-4xl ">
<DialogHeader> <DialogHeader>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">

View File

@@ -1,5 +1,18 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { KeyIcon, Loader2, MoreHorizontal, ServerIcon } from "lucide-react"; import {
KeyIcon,
Loader2,
MoreHorizontal,
ServerIcon,
Clock,
User,
Key,
Network,
Terminal,
Settings,
Pencil,
Trash2,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
@@ -24,14 +37,11 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { import {
Table, Tooltip,
TableBody, TooltipContent,
TableCaption, TooltipProvider,
TableCell, TooltipTrigger,
TableHead, } from "@/components/ui/tooltip";
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal"; import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal";
import { TerminalModal } from "../web-server/terminal-modal"; import { TerminalModal } from "../web-server/terminal-modal";
@@ -59,7 +69,7 @@ export const ShowServers = () => {
return ( return (
<div className="w-full"> <div className="w-full">
{query?.success && isCloud && <WelcomeSuscription />} {query?.success && isCloud && <WelcomeSuscription />}
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto"> <Card className="h-full p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md "> <div className="rounded-xl bg-background shadow-md ">
<CardHeader className=""> <CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2"> <CardTitle className="text-xl flex flex-row gap-2">
@@ -114,240 +124,309 @@ export const ShowServers = () => {
<HandleServers /> <HandleServers />
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-4 min-h-[25vh]"> <div className="flex flex-col gap-4 min-h-[25vh]">
<Table> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<TableCaption> {data?.map((server) => {
<div className="flex flex-col gap-4"> const canDelete = server.totalSum === 0;
See all servers const isActive = server.serverStatus === "active";
</div> const isBuildServer = server.serverType === "build";
</TableCaption> return (
<TableHeader> <Card
<TableRow> key={server.serverId}
<TableHead className="text-left">Name</TableHead> className="relative hover:shadow-lg transition-shadow flex flex-col bg-transparent"
{isCloud && ( >
<TableHead className="text-center"> <CardHeader className="pb-3">
Status <div className="flex items-start justify-between">
</TableHead> <div className="flex items-center gap-2">
)} <ServerIcon className="size-5 text-muted-foreground" />
<TableHead className="text-center"> <CardTitle className="text-lg">
Type {server.name}
</TableHead> </CardTitle>
<TableHead className="text-center"> </div>
IP Address {isActive &&
</TableHead> server.sshKeyId &&
<TableHead className="text-center"> !isBuildServer && (
Port <DropdownMenu>
</TableHead> <DropdownMenuTrigger asChild>
<TableHead className="text-center"> <Button
Username variant="ghost"
</TableHead> className="h-8 w-8 p-0"
<TableHead className="text-center"> >
SSH Key <span className="sr-only">
</TableHead> More options
<TableHead className="text-center"> </span>
Created <MoreHorizontal className="h-4 w-4" />
</TableHead> </Button>
<TableHead className="text-right"> </DropdownMenuTrigger>
Actions <DropdownMenuContent align="end">
</TableHead> <DropdownMenuLabel>
</TableRow> Advanced
</TableHeader> </DropdownMenuLabel>
<TableBody> <ShowTraefikFileSystemModal
{data?.map((server) => { serverId={server.serverId}
const canDelete = server.totalSum === 0; />
const isActive = server.serverStatus === "active"; <ShowDockerContainersModal
const isBuildServer = serverId={server.serverId}
server.serverType === "build"; />
return ( {isCloud && (
<TableRow key={server.serverId}> <ShowMonitoringModal
<TableCell className="text-left"> url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
{server.name} token={
</TableCell> server?.metricsConfig?.server
{isCloud && ( ?.token
<TableHead className="text-center"> }
/>
)}
<ShowSwarmOverviewModal
serverId={server.serverId}
/>
<ShowNodesModal
serverId={server.serverId}
/>
<ShowSchedulesModal
serverId={server.serverId}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<TooltipProvider>
<div className="flex gap-2 mt-2 flex-wrap">
{isCloud && (
<>
{server.serverStatus === "active" ? (
<Badge variant="default">
{server.serverStatus}
</Badge>
) : (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<span className="inline-block">
<Badge
variant="destructive"
className="cursor-help"
>
{server.serverStatus}
</Badge>
</span>
</TooltipTrigger>
<TooltipContent
className="max-w-xs"
side="bottom"
>
<p className="text-sm">
This server is deactivated due
to lack of payment. Please pay
your invoice to reactivate it.
If you think this is an error,
please contact support.
</p>
</TooltipContent>
</Tooltip>
)}
</>
)}
<Badge <Badge
variant={ variant={
server.serverStatus === "active" isBuildServer
? "default" ? "secondary"
: "destructive" : "default"
} }
> >
{server.serverStatus} {server.serverType}
</Badge> </Badge>
</TableHead> </div>
)} </TooltipProvider>
<TableCell className="text-center"> </CardHeader>
<Badge <CardContent className="space-y-3 flex-1 flex flex-col">
variant={ <div className="flex items-center gap-2 text-sm">
isBuildServer ? "secondary" : "default" <Network className="size-4 text-muted-foreground" />
} <span className="text-muted-foreground">
> IP:
{server.serverType} </span>
<Badge variant="outline">
{server.ipAddress}
</Badge> </Badge>
</TableCell> <span className="text-muted-foreground">
<TableCell className="text-center"> Port:
<Badge>{server.ipAddress}</Badge> </span>
</TableCell> <span className="font-medium">
<TableCell className="text-center"> {server.port}
{server.port} </span>
</TableCell> </div>
<TableCell className="text-center"> <div className="flex items-center gap-2 text-sm">
{server.username} <User className="size-4 text-muted-foreground" />
</TableCell> <span className="text-muted-foreground">
<TableCell className="text-right"> User:
<span className="text-sm text-muted-foreground"> </span>
<span className="font-medium">
{server.username}
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Key className="size-4 text-muted-foreground" />
<span className="text-muted-foreground">
SSH Key:
</span>
<span className="font-medium">
{server.sshKeyId ? "Yes" : "No"} {server.sshKeyId ? "Yes" : "No"}
</span> </span>
</TableCell> </div>
<TableCell className="text-right"> <div className="flex items-center gap-2 text-sm pt-2 border-t">
<span className="text-sm text-muted-foreground"> <Clock className="size-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
Created{" "}
{format( {format(
new Date(server.createdAt), new Date(server.createdAt),
"PPpp", "PPp",
)} )}
</span> </span>
</TableCell> </div>
<TableCell className="text-right flex justify-end"> {/* Compact Actions */}
<DropdownMenu> {isActive && (
<DropdownMenuTrigger asChild> <div className="flex items-center gap-2 pt-3 border-t mt-auto">
<Button <TooltipProvider>
variant="ghost" {server.sshKeyId && (
className="h-8 w-8 p-0" <Tooltip>
> <TooltipTrigger asChild>
<span className="sr-only"> <div>
Open menu <TerminalModal
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
Actions
</DropdownMenuLabel>
{isActive && (
<>
{server.sshKeyId && (
<TerminalModal
serverId={server.serverId}
>
<span>
{t(
"settings.common.enterTerminal",
)}
</span>
</TerminalModal>
)}
<SetupServer
serverId={server.serverId}
/>
<HandleServers
serverId={server.serverId}
/>
{server.sshKeyId &&
!isBuildServer && (
<ShowServerActions
serverId={server.serverId} serverId={server.serverId}
/> asButton={true}
)} >
</> <Button
variant="outline"
size="icon"
className="h-9 w-9"
>
<Terminal className="h-4 w-4" />
</Button>
</TerminalModal>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Terminal</p>
</TooltipContent>
</Tooltip>
)} )}
<DialogAction <Tooltip>
disabled={!canDelete} <TooltipTrigger asChild>
title={ <div>
canDelete <SetupServer
? "Delete Server"
: "Server has active services"
}
description={
canDelete ? (
"This will delete the server and all associated data"
) : (
<div className="flex flex-col gap-2">
You can not delete this server
because it has active services.
<AlertBlock type="warning">
You have active services
associated with this server,
please delete them first.
</AlertBlock>
</div>
)
}
onClick={async () => {
await mutateAsync({
serverId: server.serverId,
})
.then(() => {
refetch();
toast.success(
`Server ${server.name} deleted successfully`,
);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) => e.preventDefault()}
>
Delete Server
</DropdownMenuItem>
</DialogAction>
{isActive &&
server.sshKeyId &&
!isBuildServer && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>
Extra
</DropdownMenuLabel>
<ShowTraefikFileSystemModal
serverId={server.serverId} serverId={server.serverId}
asButton={true}
/> />
<ShowDockerContainersModal </div>
</TooltipTrigger>
<TooltipContent>
<p>Setup Server</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div>
<HandleServers
serverId={server.serverId} serverId={server.serverId}
asButton={true}
/> />
{isCloud && ( </div>
<ShowMonitoringModal </TooltipTrigger>
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`} <TooltipContent>
token={ <p>Edit Server</p>
server?.metricsConfig </TooltipContent>
?.server?.token </Tooltip>
}
{server.sshKeyId && !isBuildServer && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<ShowServerActions
serverId={server.serverId}
asButton={true}
/> />
)} </div>
</TooltipTrigger>
<TooltipContent>
<p>Web Server Actions</p>
</TooltipContent>
</Tooltip>
)}
<ShowSwarmOverviewModal <div className="flex-1" />
serverId={server.serverId}
/>
<ShowNodesModal
serverId={server.serverId}
/>
<ShowSchedulesModal <Tooltip>
serverId={server.serverId} <TooltipTrigger asChild>
/> <div>
</> <DialogAction
)} disabled={!canDelete}
</DropdownMenuContent> title={
</DropdownMenu> canDelete
</TableCell> ? "Delete Server"
</TableRow> : "Server has active services"
); }
})} description={
</TableBody> canDelete ? (
</Table> "This will delete the server and all associated data"
) : (
<div className="flex flex-col gap-2">
You can not delete this
server because it has
active services.
<AlertBlock type="warning">
You have active services
associated with this
server, please delete
them first.
</AlertBlock>
</div>
)
}
onClick={async () => {
await mutateAsync({
serverId: server.serverId,
})
.then(() => {
refetch();
toast.success(
`Server ${server.name} deleted successfully`,
);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
<Button
variant="ghost"
size="icon"
className={`h-9 w-9 ${canDelete ? "text-destructive hover:text-destructive hover:bg-destructive/10" : "text-muted-foreground hover:bg-muted"}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</DialogAction>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{canDelete
? "Delete Server"
: "Cannot delete - has active services"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</CardContent>
</Card>
);
})}
</div>
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4"> <div className="flex flex-row gap-2 flex-wrap w-full justify-end mt-4">
{data && data?.length > 0 && ( {data && data?.length > 0 && (
<div> <div>
<HandleServers /> <HandleServers />

View File

@@ -24,10 +24,16 @@ const getTerminalKey = () => {
interface Props { interface Props {
children?: React.ReactNode; children?: React.ReactNode;
serverId: string; serverId: string;
asButton?: boolean;
} }
export const TerminalModal = ({ children, serverId }: Props) => { export const TerminalModal = ({
children,
serverId,
asButton = false,
}: Props) => {
const [terminalKey, setTerminalKey] = useState<string>(getTerminalKey()); const [terminalKey, setTerminalKey] = useState<string>(getTerminalKey());
const [isOpen, setIsOpen] = useState(false);
const isLocalServer = serverId === "local"; const isLocalServer = serverId === "local";
const { data } = api.server.one.useQuery( const { data } = api.server.one.useQuery(
@@ -43,15 +49,20 @@ export const TerminalModal = ({ children, serverId }: Props) => {
}; };
return ( return (
<Dialog> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> {asButton ? (
<DialogTrigger asChild>{children}</DialogTrigger>
) : (
<DropdownMenuItem <DropdownMenuItem
className="w-full cursor-pointer space-x-3" className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()} onSelect={(e) => {
e.preventDefault();
setIsOpen(true);
}}
> >
{children} {children}
</DropdownMenuItem> </DropdownMenuItem>
</DialogTrigger> )}
<DialogContent <DialogContent
className="sm:max-w-7xl" className="sm:max-w-7xl"
onEscapeKeyDown={(event) => event.preventDefault()} onEscapeKeyDown={(event) => event.preventDefault()}

View File

@@ -1,22 +1,36 @@
import Link from "next/link"; import Link from "next/link";
import { Fragment } from "react"; import { Fragment } from "react";
import { ChevronDown } from "lucide-react";
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
BreadcrumbLink, BreadcrumbLink,
BreadcrumbList, BreadcrumbList,
BreadcrumbSeparator, BreadcrumbSeparator,
BreadcrumbPage,
} from "@/components/ui/breadcrumb"; } from "@/components/ui/breadcrumb";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar"; import { SidebarTrigger } from "@/components/ui/sidebar";
interface Props { interface BreadcrumbEntry {
list: { name: string;
href?: string;
dropdownItems?: {
name: string; name: string;
href?: string; href: string;
}[]; }[];
} }
interface Props {
list: BreadcrumbEntry[];
}
export const BreadcrumbSidebar = ({ list }: Props) => { export const BreadcrumbSidebar = ({ list }: Props) => {
return ( return (
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12"> <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
@@ -29,13 +43,29 @@ export const BreadcrumbSidebar = ({ list }: Props) => {
{list.map((item, index) => ( {list.map((item, index) => (
<Fragment key={item.name}> <Fragment key={item.name}>
<BreadcrumbItem className="block"> <BreadcrumbItem className="block">
<BreadcrumbLink href={item?.href} asChild={!!item?.href}> {item.dropdownItems && item.dropdownItems.length > 0 ? (
{item.href ? ( <DropdownMenu>
<Link href={item?.href}>{item?.name}</Link> <DropdownMenuTrigger className="flex items-center gap-1 hover:text-foreground transition-colors outline-none">
) : ( {item.name}
item?.name <ChevronDown className="h-4 w-4 opacity-50" />
)} </DropdownMenuTrigger>
</BreadcrumbLink> <DropdownMenuContent align="start">
{item.dropdownItems.map((subItem) => (
<DropdownMenuItem key={subItem.href} asChild>
<Link href={subItem.href}>{subItem.name}</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<BreadcrumbLink href={item?.href} asChild={!!item?.href}>
{item.href ? (
<Link href={item?.href}>{item?.name}</Link>
) : (
<BreadcrumbPage>{item?.name}</BreadcrumbPage>
)}
</BreadcrumbLink>
)}
</BreadcrumbItem> </BreadcrumbItem>
{index + 1 < list.length && ( {index + 1 < list.length && (
<BreadcrumbSeparator className="block" /> <BreadcrumbSeparator className="block" />

View File

@@ -1,6 +1,6 @@
{ {
"name": "dokploy", "name": "dokploy",
"version": "v0.26.2", "version": "v0.26.3",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
@@ -33,7 +33,7 @@
"docker:build:canary": "./docker/build.sh canary", "docker:build:canary": "./docker/build.sh canary",
"docker:push:canary": "./docker/push.sh canary", "docker:push:canary": "./docker/push.sh canary",
"version": "echo $(node -p \"require('./package.json').version\")", "version": "echo $(node -p \"require('./package.json').version\")",
"test": "vitest --config __test__/vitest.config.ts", "test": "vitest --config __test__/vitest.config.ts volume-backups",
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts" "generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
}, },
"dependencies": { "dependencies": {

View File

@@ -279,6 +279,16 @@ const EnvironmentPage = (
const [isBulkActionLoading, setIsBulkActionLoading] = useState(false); const [isBulkActionLoading, setIsBulkActionLoading] = useState(false);
const { projectId, environmentId } = props; const { projectId, environmentId } = props;
const { data: auth } = api.user.get.useQuery(); const { data: auth } = api.user.get.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: projectId,
});
const environmentDropdownItems =
environments?.map((env) => ({
name: env.name,
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
})) || [];
const [sortBy, setSortBy] = useState<string>(() => { const [sortBy, setSortBy] = useState<string>(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
return localStorage.getItem("servicesSort") || "lastDeploy-desc"; return localStorage.getItem("servicesSort") || "lastDeploy-desc";
@@ -863,6 +873,7 @@ const EnvironmentPage = (
}, },
{ {
name: currentEnvironment.name, name: currentEnvironment.name,
dropdownItems: environmentDropdownItems,
}, },
]} ]}
/> />

View File

@@ -91,6 +91,15 @@ const Service = (
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: auth } = api.user.get.useQuery(); const { data: auth } = api.user.get.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.project?.projectId || "",
});
const environmentDropdownItems =
environments?.map((env) => ({
name: env.name,
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
})) || [];
return ( return (
<div className="pb-10"> <div className="pb-10">
<UseKeyboardNav forPage="application" /> <UseKeyboardNav forPage="application" />
@@ -98,11 +107,11 @@ const Service = (
list={[ list={[
{ name: "Projects", href: "/dashboard/projects" }, { name: "Projects", href: "/dashboard/projects" },
{ {
name: data?.environment.project.name || "", name: data?.environment?.project?.name || "",
}, },
{ {
name: data?.environment?.name || "", name: data?.environment?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`, dropdownItems: environmentDropdownItems,
}, },
{ {
name: data?.name || "", name: data?.name || "",

View File

@@ -80,6 +80,14 @@ const Service = (
const { data: auth } = api.user.get.useQuery(); const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
});
const environmentDropdownItems =
environments?.map((env) => ({
name: env.name,
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
})) || [];
return ( return (
<div className="pb-10"> <div className="pb-10">
@@ -92,7 +100,7 @@ const Service = (
}, },
{ {
name: data?.environment?.name || "", name: data?.environment?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`, dropdownItems: environmentDropdownItems,
}, },
{ {
name: data?.name || "", name: data?.name || "",

View File

@@ -62,6 +62,15 @@ const Mariadb = (
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
});
const environmentDropdownItems =
environments?.map((env) => ({
name: env.name,
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
})) || [];
return ( return (
<div className="pb-10"> <div className="pb-10">
<UseKeyboardNav forPage="mariadb" /> <UseKeyboardNav forPage="mariadb" />
@@ -73,7 +82,7 @@ const Mariadb = (
}, },
{ {
name: data?.environment?.name || "", name: data?.environment?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`, dropdownItems: environmentDropdownItems,
}, },
{ {
name: data?.name || "", name: data?.name || "",

View File

@@ -61,6 +61,14 @@ const Mongo = (
const { data: auth } = api.user.get.useQuery(); const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
});
const environmentDropdownItems =
environments?.map((env) => ({
name: env.name,
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
})) || [];
return ( return (
<div className="pb-10"> <div className="pb-10">
@@ -73,7 +81,7 @@ const Mongo = (
}, },
{ {
name: data?.environment?.name || "", name: data?.environment?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`, dropdownItems: environmentDropdownItems,
}, },
{ {
name: data?.name || "", name: data?.name || "",

View File

@@ -60,6 +60,14 @@ const MySql = (
const { data: auth } = api.user.get.useQuery(); const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
});
const environmentDropdownItems =
environments?.map((env) => ({
name: env.name,
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
})) || [];
return ( return (
<div className="pb-10"> <div className="pb-10">
@@ -72,7 +80,7 @@ const MySql = (
}, },
{ {
name: data?.environment?.name || "", name: data?.environment?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`, dropdownItems: environmentDropdownItems,
}, },
{ {
name: data?.name || "", name: data?.name || "",

View File

@@ -60,6 +60,14 @@ const Postgresql = (
const { data: auth } = api.user.get.useQuery(); const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
});
const environmentDropdownItems =
environments?.map((env) => ({
name: env.name,
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
})) || [];
return ( return (
<div className="pb-10"> <div className="pb-10">
@@ -72,7 +80,7 @@ const Postgresql = (
}, },
{ {
name: data?.environment?.name || "", name: data?.environment?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`, dropdownItems: environmentDropdownItems,
}, },
{ {
name: data?.name || "", name: data?.name || "",

View File

@@ -60,6 +60,14 @@ const Redis = (
const { data: auth } = api.user.get.useQuery(); const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
});
const environmentDropdownItems =
environments?.map((env) => ({
name: env.name,
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
})) || [];
return ( return (
<div className="pb-10"> <div className="pb-10">
@@ -72,7 +80,7 @@ const Redis = (
}, },
{ {
name: data?.environment?.name || "", name: data?.environment?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`, dropdownItems: environmentDropdownItems,
}, },
{ {
name: data?.name || "", name: data?.name || "",

View File

@@ -87,7 +87,7 @@ export const mariadbRouter = createTRPCRouter({
type: "volume", type: "volume",
}); });
return true; return newMariadb;
} catch (error) { } catch (error) {
if (error instanceof TRPCError) { if (error instanceof TRPCError) {
throw error; throw error;

View File

@@ -87,7 +87,7 @@ export const mongoRouter = createTRPCRouter({
type: "volume", type: "volume",
}); });
return true; return newMongo;
} catch (error) { } catch (error) {
if (error instanceof TRPCError) { if (error instanceof TRPCError) {
throw error; throw error;

View File

@@ -89,7 +89,7 @@ export const mysqlRouter = createTRPCRouter({
type: "volume", type: "volume",
}); });
return true; return newMysql;
} catch (error) { } catch (error) {
if (error instanceof TRPCError) { if (error instanceof TRPCError) {
throw error; throw error;

View File

@@ -91,7 +91,7 @@ export const postgresRouter = createTRPCRouter({
type: "volume", type: "volume",
}); });
return true; return newPostgres;
} catch (error) { } catch (error) {
if (error instanceof TRPCError) { if (error instanceof TRPCError) {
throw error; throw error;

View File

@@ -15,6 +15,7 @@ import {
apiFindOneRegistry, apiFindOneRegistry,
apiRemoveRegistry, apiRemoveRegistry,
apiTestRegistry, apiTestRegistry,
apiTestRegistryById,
apiUpdateRegistry, apiUpdateRegistry,
registry, registry,
} from "@/server/db/schema"; } from "@/server/db/schema";
@@ -109,6 +110,67 @@ export const registryRouter = createTRPCRouter({
}); });
} }
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Error testing the registry",
cause: error,
});
}
}),
testRegistryById: protectedProcedure
.input(apiTestRegistryById)
.mutation(async ({ input, ctx }) => {
try {
// Get the full registry with password from database
const registryData = await db.query.registry.findFirst({
where: eq(registry.registryId, input.registryId ?? ""),
});
if (!registryData) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Registry not found",
});
}
if (registryData.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to test this registry",
});
}
const args = [
"login",
registryData.registryUrl,
"--username",
registryData.username,
"--password-stdin",
];
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Select a server to test the registry",
});
}
if (input.serverId && input.serverId !== "none") {
await execAsyncRemote(
input.serverId,
`echo ${registryData.password} | docker ${args.join(" ")}`,
);
} else {
await execFileAsync("docker", args, {
input: Buffer.from(registryData.password).toString(),
});
}
return true; return true;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({

View File

@@ -80,6 +80,14 @@ export const apiTestRegistry = createSchema.pick({}).extend({
serverId: z.string().optional(), serverId: z.string().optional(),
}); });
export const apiTestRegistryById = createSchema
.pick({
registryId: true,
})
.extend({
serverId: z.string().optional(),
});
export const apiRemoveRegistry = createSchema export const apiRemoveRegistry = createSchema
.pick({ .pick({
registryId: true, registryId: true,

View File

@@ -42,7 +42,7 @@ const { handler, api } = betterAuth({
}, },
}); });
if (admin) { if (admin?.user) {
return [ return [
...(admin.user.serverIp ...(admin.user.serverIp
? [`http://${admin.user.serverIp}:3000`] ? [`http://${admin.user.serverIp}:3000`]

View File

@@ -170,12 +170,12 @@ export function processValue(
} }
// If not a utility function, try to get from variables // If not a utility function, try to get from variables
return variables[varName] || match; return varName in variables ? (variables[varName] ?? match) : match;
}); });
// Then replace any remaining ${var} with their values from variables // Then replace any remaining ${var} with their values from variables
processedValue = processedValue.replace(/\${([^}]+)}/g, (match, varName) => { processedValue = processedValue.replace(/\${([^}]+)}/g, (match, varName) => {
return variables[varName] || match; return varName in variables ? (variables[varName] ?? match) : match;
}); });
return processedValue; return processedValue;

View File

@@ -54,7 +54,7 @@ if [ "$REPLICA_STATUS" != "1" ]; then
mongosh --eval ' mongosh --eval '
rs.initiate({ rs.initiate({
_id: "rs0", _id: "rs0",
members: [{ _id: 0, host: "localhost:27017", priority: 1 }] members: [{ _id: 0, host: "${appName}:27017", priority: 1 }]
}); });
// Wait for the replica set to initialize // Wait for the replica set to initialize

View File

@@ -27,6 +27,9 @@ export const backupVolume = async (
echo "Turning off volume backup: ${turnOff ? "Yes" : "No"}" echo "Turning off volume backup: ${turnOff ? "Yes" : "No"}"
echo "Starting volume backup" echo "Starting volume backup"
echo "Dir: ${volumeBackupPath}" echo "Dir: ${volumeBackupPath}"
echo "Ensuring ubuntu image is available..."
docker image inspect ubuntu:latest > /dev/null 2>&1 || docker pull ubuntu:latest
echo "Ubuntu image ready ✅"
docker run --rm \ docker run --rm \
-v ${volumeName}:/volume_data \ -v ${volumeName}:/volume_data \
-v ${volumeBackupPath}:/backup \ -v ${volumeBackupPath}:/backup \