Compare commits

..

2 Commits

Author SHA1 Message Date
Mauricio Siu
2880fb9748 feat: enhance transfer service with auto-deployment and logging
Refactor the TransferService component to include automatic deployment after successful transfers for various service types (application, compose, postgres, mysql, mariadb, mongo, redis). Implement logging functionality to capture transfer progress and errors, improving user feedback during the transfer process. Update related API routers to support these enhancements, ensuring a seamless transfer and deployment experience.
2026-04-15 12:29:07 -06:00
Mauricio Siu
fcbd226796 feat: implement service transfer functionality
Add a new TransferService component to facilitate the transfer of various services (applications, databases, etc.) between servers. This includes updates to the ShowDatabaseAdvancedSettings component to support serverId, and integration of transfer functionality in the application, compose, mariadb, mongo, mysql, postgres, and redis routers. The transfer process includes scanning for transfer readiness and executing the transfer with appropriate permissions and error handling.
2026-04-13 22:36:22 -06:00
188 changed files with 3565 additions and 29310 deletions

View File

@@ -138,8 +138,6 @@ jobs:
needs: [combine-manifests]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -162,80 +160,3 @@ jobs:
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
sync-version:
needs: [generate-release]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Sync version to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
cd /tmp/mcp-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
npm install -g pnpm
pnpm install
pnpm run fetch-openapi
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.version }}"
- name: Sync version to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
cd /tmp/cli-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.version }}"
- name: Sync version to SDK repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
cd /tmp/sdk-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.version }}"

View File

@@ -68,66 +68,3 @@ jobs:
echo "✅ OpenAPI synced to website successfully"
- name: Sync to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git mcp-repo
cd mcp-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to MCP repository successfully"
- name: Sync to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git cli-repo
cd cli-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to CLI repository successfully"
- name: Sync to SDK repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git sdk-repo
cd sdk-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to SDK repository successfully"

View File

@@ -4,8 +4,5 @@
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
}
}

View File

@@ -66,7 +66,7 @@ COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
HEALTHCHECK --interval=10s --timeout=3s --retries=10 \
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]

8403
api-1.json

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,3 @@
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
PORT=3000
NODE_ENV=development
# Managed Servers (Dokploy Cloud only) — API token from https://hpanel.hostinger.com/profile/api
HOSTINGER_API_KEY=

View File

@@ -494,49 +494,4 @@ describe("processTemplate", () => {
expect(result.mounts).toHaveLength(1);
});
});
describe("isolated deployment config", () => {
it("should default to isolated=true when not specified", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [],
env: {},
},
};
expect(template.config.isolated).toBeUndefined();
// undefined !== false => isolatedDeployment = true
expect(template.config.isolated !== false).toBe(true);
});
it("should be isolated when isolated=true is explicitly set", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
isolated: true,
domains: [],
env: {},
},
};
expect(template.config.isolated !== false).toBe(true);
});
it("should disable isolated deployment when isolated=false", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
isolated: false,
domains: [],
env: {},
},
};
expect(template.config.isolated !== false).toBe(false);
});
});
});

View File

@@ -30,7 +30,9 @@ describe("helpers functions", () => {
const domain = processValue("${domain}", {}, mockSchema);
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
expect(
domain.endsWith(`${mockSchema.serverIp.replaceAll(".", "-")}.sslip.io`),
domain.endsWith(
`${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`,
),
).toBeTruthy();
});
});

View File

@@ -21,9 +21,9 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { RouterOutputs } from "@/utils/api";
import { DnsHelperModal } from "./dns-helper-modal";
import { AddDomain } from "./handle-domain";
import type { ValidationStates } from "./show-domains";
import { AddDomain } from "./handle-domain";
import { DnsHelperModal } from "./dns-helper-modal";
export type Domain =
| RouterOutputs["domain"]["byApplicationId"][0]
@@ -168,7 +168,7 @@ export const createColumns = ({
{domain.certificateType}
</Badge>
)}
{!domain.host.includes("sslip.io") && (
{!domain.host.includes("traefik.me") && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -256,7 +256,7 @@ export const createColumns = ({
return (
<div className="flex items-center gap-2">
{!domain.host.includes("sslip.io") && (
{!domain.host.includes("traefik.me") && (
<DnsHelperModal
domain={{
host: domain.host,

View File

@@ -225,7 +225,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
const https = form.watch("https");
const domainType = form.watch("domainType");
const host = form.watch("host");
const isTraefikMeDomain = host?.includes("sslip.io") || false;
const isTraefikMeDomain = host?.includes("traefik.me") || false;
useEffect(() => {
if (data) {
@@ -513,7 +513,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
render={({ field }) => (
<FormItem>
{!canGenerateTraefikMeDomains &&
field.value.includes("sslip.io") && (
field.value.includes("traefik.me") && (
<AlertBlock type="warning">
You need to set an IP address in your{" "}
<Link
@@ -524,12 +524,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"}
</Link>{" "}
to make your sslip.io domain work.
to make your traefik.me domain work.
</AlertBlock>
)}
{isTraefikMeDomain && (
<AlertBlock type="info">
<strong>Note:</strong> sslip.io is a public HTTP
<strong>Note:</strong> traefik.me is a public HTTP
service and does not support SSL/HTTPS. HTTPS and
certificate options will not have any effect.
</AlertBlock>
@@ -567,7 +567,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
sideOffset={5}
className="max-w-[10rem]"
>
<p>Generate sslip.io domain</p>
<p>Generate traefik.me domain</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -666,7 +666,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
<div className="space-y-0.5">
<FormLabel>Custom Entrypoint</FormLabel>
<FormDescription>
Use custom entrypoint for domain
Use custom entrypoint for domina
<br />
"web" and/or "websecure" is used by default.
</FormDescription>

View File

@@ -425,7 +425,7 @@ export const ShowDomains = ({ id, type }: Props) => {
</Badge>
)}
<div className="flex gap-2 flex-wrap">
{!item.host.includes("sslip.io") && (
{!item.host.includes("traefik.me") && (
<DnsHelperModal
domain={{
host: item.host,

View File

@@ -5,7 +5,6 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
@@ -58,10 +57,7 @@ const BitbucketProviderSchema = z.object({
slug: z.string().optional(),
})
.required(),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch is required"),
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().optional(),

View File

@@ -6,7 +6,6 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GitIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -42,10 +41,7 @@ const GitProviderSchema = z.object({
repositoryURL: z.string().min(1, {
message: "Repository URL is required",
}),
branch: z
.string()
.min(1, "Branch required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch required"),
sshKey: z.string().optional(),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
@@ -111,103 +107,110 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 items-start">
<FormField
control={form.control}
name="repositoryURL"
render={({ field }) => (
<FormItem className="col-span-2 lg:col-span-3">
<div className="flex items-center justify-between h-5">
<FormLabel>Repository URL</FormLabel>
{field.value?.startsWith("https://") && (
<Link
href={field.value}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<GitIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<FormControl>
<Input placeholder="Repository URL" {...field} />
</FormControl>
<FormMessage />
</FormItem>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="grid md:grid-cols-2 gap-4">
<div className="flex items-end col-span-2 gap-4">
<div className="grow">
<FormField
control={form.control}
name="repositoryURL"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Repository URL</FormLabel>
{field.value?.startsWith("https://") && (
<Link
href={field.value}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<GitIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<FormControl>
<Input placeholder="Repository URL" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{sshKeys && sshKeys.length > 0 ? (
<FormField
control={form.control}
name="sshKey"
render={({ field }) => (
<FormItem className="basis-40">
<FormLabel className="w-full inline-flex justify-between">
SSH Key
<LockIcon className="size-4 text-muted-foreground" />
</FormLabel>
<FormControl>
<Select
key={field.value}
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a key" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{sshKeys?.map((sshKey) => (
<SelectItem
key={sshKey.sshKeyId}
value={sshKey.sshKeyId}
>
{sshKey.name}
</SelectItem>
))}
<SelectItem value="none">None</SelectItem>
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
) : (
<Button
variant="secondary"
onClick={() => router.push("/dashboard/settings/ssh-keys")}
type="button"
>
<KeyRoundIcon className="size-4" /> Add SSH Key
</Button>
)}
/>
{sshKeys && sshKeys.length > 0 ? (
</div>
<div className="space-y-4">
<FormField
control={form.control}
name="sshKey"
name="branch"
render={({ field }) => (
<FormItem className="col-span-2 lg:col-span-1">
<FormLabel className="w-full inline-flex justify-between">
SSH Key
<LockIcon className="size-4 text-muted-foreground" />
</FormLabel>
<FormItem>
<FormLabel>Branch</FormLabel>
<FormControl>
<Select
key={field.value}
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a key" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{sshKeys?.map((sshKey) => (
<SelectItem
key={sshKey.sshKeyId}
value={sshKey.sshKeyId}
>
{sshKey.name}
</SelectItem>
))}
<SelectItem value="none">None</SelectItem>
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<Input placeholder="Branch" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
) : (
<Button
variant="secondary"
onClick={() => router.push("/dashboard/settings/ssh-keys")}
type="button"
className="col-span-2 lg:col-span-1 lg:mt-7"
>
<KeyRoundIcon className="size-4" /> Add SSH Key
</Button>
)}
<FormField
control={form.control}
name="branch"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Branch</FormLabel>
<FormControl>
<Input placeholder="Branch" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="buildPath"
render={({ field }) => (
<FormItem className="col-span-2">
<FormItem>
<FormLabel>Build Path</FormLabel>
<FormControl>
<Input placeholder="/" {...field} />
@@ -220,7 +223,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="col-span-2 lg:col-span-4">
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>

View File

@@ -5,7 +5,6 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GiteaIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
@@ -73,10 +72,7 @@ const GiteaProviderSchema = z.object({
owner: z.string().min(1, "Owner is required"),
})
.required(),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch is required"),
giteaId: z.string().min(1, "Gitea Provider is required"),
watchPaths: z.array(z.string()).default([]),
enableSubmodules: z.boolean().optional(),

View File

@@ -5,7 +5,6 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -56,10 +55,7 @@ const GithubProviderSchema = z.object({
owner: z.string().min(1, "Owner is required"),
})
.required(),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch is required"),
githubId: z.string().min(1, "Github Provider is required"),
watchPaths: z.array(z.string()).optional(),
triggerType: z.enum(["push", "tag"]).default("push"),

View File

@@ -5,7 +5,6 @@ import { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GitlabIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
@@ -59,10 +58,7 @@ const GitlabProviderSchema = z.object({
id: z.number().nullable(),
})
.required(),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch is required"),
gitlabId: z.string().min(1, "Gitlab Provider is required"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),

View File

@@ -58,7 +58,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
<CardHeader>
<CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 lg:flex lg:flex-row lg:flex-wrap gap-4">
<CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
{canDeploy && (
<DialogAction
@@ -274,14 +274,14 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
>
<Button
variant="outline"
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2 col-span-2"
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Terminal className="size-4 mr-1" />
Open Terminal
</Button>
</DockerTerminalModal>
{canUpdateService && (
<div className="flex flex-row items-center gap-2 justify-between rounded-md px-4 py-2 border col-span-2 md:col-span-1">
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Autodeploy</span>
<Switch
aria-label="Toggle autodeploy"
@@ -305,7 +305,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
)}
{canUpdateService && (
<div className="flex flex-row items-center gap-2 justify-between rounded-md px-4 py-2 border col-span-2 md:col-span-1">
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Clean Cache</span>
<Switch
aria-label="Toggle clean cache"

View File

@@ -87,7 +87,7 @@ export const AddPreviewDomain = ({
});
const host = form.watch("host");
const isTraefikMeDomain = host?.includes("sslip.io") || false;
const isTraefikMeDomain = host?.includes("traefik.me") || false;
useEffect(() => {
if (data) {
@@ -162,7 +162,7 @@ export const AddPreviewDomain = ({
<FormItem>
{isTraefikMeDomain && (
<AlertBlock type="info">
<strong>Note:</strong> sslip.io is a public HTTP
<strong>Note:</strong> traefik.me is a public HTTP
service and does not support SSL/HTTPS. HTTPS and
certificate options will not have any effect.
</AlertBlock>
@@ -202,7 +202,7 @@ export const AddPreviewDomain = ({
sideOffset={5}
className="max-w-[10rem]"
>
<p>Generate sslip.io domain</p>
<p>Generate traefik.me domain</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>

View File

@@ -88,7 +88,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
const form = useForm<Schema>({
defaultValues: {
env: "",
wildcardDomain: "*.sslip.io",
wildcardDomain: "*.traefik.me",
port: 3000,
previewLimit: 3,
previewLabels: [],
@@ -102,7 +102,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
const previewHttps = form.watch("previewHttps");
const wildcardDomain = form.watch("wildcardDomain");
const isTraefikMeDomain = wildcardDomain?.includes("sslip.io") || false;
const isTraefikMeDomain = wildcardDomain?.includes("traefik.me") || false;
useEffect(() => {
setIsEnabled(data?.isPreviewDeploymentsActive || false);
@@ -114,7 +114,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
env: data.previewEnv || "",
buildArgs: data.previewBuildArgs || "",
buildSecrets: data.previewBuildSecrets || "",
wildcardDomain: data.previewWildcard || "*.sslip.io",
wildcardDomain: data.previewWildcard || "*.traefik.me",
port: data.previewPort || 3000,
previewLabels: data.previewLabels || [],
previewLimit: data.previewLimit || 3,
@@ -173,7 +173,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
<div className="grid gap-4">
{isTraefikMeDomain && (
<AlertBlock type="info">
<strong>Note:</strong> sslip.io is a public HTTP service and
<strong>Note:</strong> traefik.me is a public HTTP service and
does not support SSL/HTTPS. HTTPS and certificate options will
not have any effect.
</AlertBlock>
@@ -192,7 +192,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
<FormItem>
<FormLabel>Wildcard Domain</FormLabel>
<FormControl>
<Input placeholder="*.sslip.io" {...field} />
<Input placeholder="*.traefik.me" {...field} />
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -80,7 +80,6 @@ export const commonCronExpressions = [
const formSchema = z
.object({
name: z.string().min(1, "Name is required"),
description: z.string().optional(),
cronExpression: z.string().min(1, "Cron expression is required"),
shellType: z.enum(["bash", "sh"]).default("bash"),
command: z.string(),
@@ -225,7 +224,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
resolver: standardSchemaResolver(formSchema),
defaultValues: {
name: "",
description: "",
cronExpression: "",
shellType: "bash",
command: "",
@@ -265,7 +263,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
if (scheduleId && schedule) {
form.reset({
name: schedule.name,
description: schedule.description || "",
cronExpression: schedule.cronExpression,
shellType: schedule.shellType,
command: schedule.command,
@@ -482,26 +479,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
placeholder="Backs up the database every day at midnight"
{...field}
/>
</FormControl>
<FormDescription>
Optional description of what this schedule does
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<ScheduleFormField
name="cronExpression"
formControl={form.control}

View File

@@ -125,11 +125,6 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
{schedule.enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
{schedule.description && (
<p className="text-xs text-muted-foreground/70 [overflow-wrap:anywhere] line-clamp-2">
{schedule.description}
</p>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
<Badge
variant="outline"

View File

@@ -2,10 +2,6 @@ import { Loader2, MoreHorizontal, RefreshCw } from "lucide-react";
import dynamic from "next/dynamic";
import { useState } from "react";
import { toast } from "sonner";
import { ShowContainerConfig } from "@/components/dashboard/docker/config/show-container-config";
import { ShowContainerMounts } from "@/components/dashboard/docker/mounts/show-container-mounts";
import { ShowContainerNetworks } from "@/components/dashboard/docker/networks/show-container-networks";
import { DockerTerminalModal } from "@/components/dashboard/docker/terminal/docker-terminal-modal";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -40,6 +36,10 @@ import {
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
import { ShowContainerConfig } from "@/components/dashboard/docker/config/show-container-config";
import { ShowContainerMounts } from "@/components/dashboard/docker/mounts/show-container-mounts";
import { ShowContainerNetworks } from "@/components/dashboard/docker/networks/show-container-networks";
import { DockerTerminalModal } from "@/components/dashboard/docker/terminal/docker-terminal-modal";
const DockerLogsId = dynamic(
() =>

View File

@@ -49,12 +49,12 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
const composeFile = form.watch("composeFile");
useEffect(() => {
if (data) {
if (data && !composeFile) {
form.reset({
composeFile: data.composeFile || "",
});
}
}, [form, data]);
}, [form, form.reset, data]);
useEffect(() => {
if (data?.composeFile !== undefined) {

View File

@@ -5,7 +5,6 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
@@ -58,10 +57,7 @@ const BitbucketProviderSchema = z.object({
slug: z.string().optional(),
})
.required(),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch is required"),
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),

View File

@@ -6,7 +6,6 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GitIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -42,10 +41,7 @@ const GitProviderSchema = z.object({
repositoryURL: z.string().min(1, {
message: "Repository URL is required",
}),
branch: z
.string()
.min(1, "Branch required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch required"),
sshKey: z.string().optional(),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),

View File

@@ -1,11 +1,10 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import { CheckIcon, ChevronsUpDown, Plus, X, HelpCircle } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GiteaIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
@@ -58,10 +57,7 @@ const GiteaProviderSchema = z.object({
owner: z.string().min(1, "Owner is required"),
})
.required(),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch is required"),
giteaId: z.string().min(1, "Gitea Provider is required"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),

View File

@@ -1,4 +1,3 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
import Link from "next/link";
@@ -56,10 +55,7 @@ const GithubProviderSchema = z.object({
owner: z.string().min(1, "Owner is required"),
})
.required(),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch is required"),
githubId: z.string().min(1, "Github Provider is required"),
watchPaths: z.array(z.string()).optional(),
triggerType: z.enum(["push", "tag"]).default("push"),

View File

@@ -5,7 +5,6 @@ import { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GitlabIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
@@ -59,10 +58,7 @@ const GitlabProviderSchema = z.object({
gitlabPathNamespace: z.string().min(1),
})
.required(),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch is required"),
gitlabId: z.string().min(1, "Gitlab Provider is required"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),

View File

@@ -288,6 +288,7 @@ export const RestoreBackup = ({
toast.error("Please select a database type");
return;
}
console.log({ data });
setIsDeploying(true);
};

View File

@@ -1,14 +1,5 @@
"use client";
import copy from "copy-to-clipboard";
import {
Bot,
Check,
Copy,
Loader2,
RotateCcw,
Settings,
X,
} from "lucide-react";
import { Bot, Loader2, RotateCcw, Settings, X } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import ReactMarkdown from "react-markdown";
@@ -39,7 +30,6 @@ const MAX_LOG_LINES = 200;
export function AnalyzeLogs({ logs, context }: Props) {
const [open, setOpen] = useState(false);
const [aiId, setAiId] = useState<string>("");
const [copied, setCopied] = useState(false);
const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, {
enabled: open,
});
@@ -62,15 +52,6 @@ export function AnalyzeLogs({ logs, context }: Props) {
mutate({ aiId, logs: logsText, context });
};
const handleCopy = () => {
if (!data?.analysis) return;
const success = copy(data.analysis);
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<Popover
open={open}
@@ -90,7 +71,7 @@ export function AnalyzeLogs({ logs, context }: Props) {
disabled={logs.length === 0}
title="Analyze logs with AI"
>
<Bot className="mr-2 size-4" />
<Bot className="mr-2 h-4 w-4" />
AI
</Button>
</PopoverTrigger>
@@ -187,18 +168,6 @@ export function AnalyzeLogs({ logs, context }: Props) {
)}
Re-analyze
</Button>
<Button
size="sm"
variant="outline"
onClick={handleCopy}
title="Copy analysis to clipboard"
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
<Button
size="sm"
variant="ghost"

View File

@@ -347,13 +347,11 @@ export const DockerLogsId: React.FC<Props> = ({
title={isPaused ? "Resume logs" : "Pause logs"}
>
{isPaused ? (
<Play className="size-4" />
<Play className="mr-2 h-4 w-4" />
) : (
<Pause className="size-4" />
<Pause className="mr-2 h-4 w-4" />
)}
<span className="hidden lg:ml-2 lg:inline">
{isPaused ? "Resume" : "Pause"}
</span>
{isPaused ? "Resume" : "Pause"}
</Button>
<Button
variant="outline"
@@ -364,13 +362,11 @@ export const DockerLogsId: React.FC<Props> = ({
title="Copy logs to clipboard"
>
{copied ? (
<Check className="size-4" />
<Check className="mr-2 h-4 w-4" />
) : (
<Copy className="size-4" />
<Copy className="mr-2 h-4 w-4" />
)}
<span className="hidden lg:ml-2 lg:inline">
{copied ? "Copied" : "Copy"}
</span>
Copy
</Button>
<Button
variant="outline"
@@ -378,18 +374,17 @@ export const DockerLogsId: React.FC<Props> = ({
className="h-9 sm:w-auto w-full"
onClick={handleDownload}
disabled={filteredLogs.length === 0 || !data?.Name}
title="Download logs as text file"
>
<DownloadIcon className="size-4" />
<span className="hidden lg:ml-2 lg:inline">Download logs</span>
<DownloadIcon className="mr-2 h-4 w-4" />
Download logs
</Button>
<AnalyzeLogs logs={filteredLogs} context="runtime" />
</div>
</div>
{isPaused && (
<AlertBlock type="warning" className="items-center">
<AlertBlock type="warning">
<div className="flex items-center gap-2">
<Pause className="size-4" />
<Pause className="h-4 w-4" />
<span>
Logs paused
{messageBuffer.length > 0 && (

View File

@@ -1,4 +1,3 @@
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
@@ -16,6 +15,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { api } from "@/utils/api";
interface Props {

View File

@@ -1,4 +1,3 @@
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
@@ -16,6 +15,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { api } from "@/utils/api";
interface Props {

View File

@@ -26,8 +26,8 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import {
type UploadFileToContainer,
uploadFileToContainerSchema,
type UploadFileToContainer,
} from "@/utils/schema";
interface Props {

View File

@@ -1,291 +0,0 @@
import { formatDistanceToNow } from "date-fns";
import { ArrowRight, Rocket, Server } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { api } from "@/utils/api";
type DeploymentStatus = "idle" | "running" | "done" | "error";
const statusDotClass: Record<string, string> = {
done: "bg-emerald-500",
running: "bg-amber-500",
error: "bg-red-500",
idle: "bg-muted-foreground/40",
};
function getServiceInfo(d: any) {
const app = d.application;
const comp = d.compose;
const serverName: string =
d.server?.name ?? app?.server?.name ?? comp?.server?.name ?? "Dokploy";
if (app?.environment?.project && app.environment) {
return {
name: app.name as string,
environment: app.environment.name as string,
projectName: app.environment.project.name as string,
serverName,
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
};
}
if (comp?.environment?.project && comp.environment) {
return {
name: comp.name as string,
environment: comp.environment.name as string,
projectName: comp.environment.project.name as string,
serverName,
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
};
}
return null;
}
function StatCard({
label,
value,
delta,
}: {
label: string;
value: string;
delta?: string;
}) {
return (
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between">
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</span>
<div className="flex flex-col gap-1">
<span className="text-3xl font-semibold tracking-tight">{value}</span>
{delta && (
<span className="text-xs text-muted-foreground">{delta}</span>
)}
</div>
</div>
);
}
function StatusListCard({
label,
items,
}: {
label: string;
items: { dotClass: string; label: string; count: number }[];
}) {
return (
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col gap-3">
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</span>
<ul className="flex flex-col gap-1.5">
{items.map((item) => (
<li key={item.label} className="flex items-center gap-2.5 text-sm">
<span
className={`size-2 rounded-full shrink-0 ${item.dotClass}`}
aria-hidden
/>
<span className="font-semibold tabular-nums w-8">{item.count}</span>
<span className="text-muted-foreground">{item.label}</span>
</li>
))}
</ul>
</div>
);
}
export const ShowHome = () => {
const { data: auth } = api.user.get.useQuery();
const { data: homeStats } = api.project.homeStats.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const canReadDeployments = !!permissions?.deployment.read;
const { data: deployments } = api.deployment.allCentralized.useQuery(
undefined,
{
enabled: canReadDeployments,
refetchInterval: 10000,
},
);
const firstName = auth?.user?.firstName?.trim();
const totals = homeStats ?? {
projects: 0,
environments: 0,
applications: 0,
compose: 0,
databases: 0,
services: 0,
};
const statusBreakdown = homeStats?.status ?? {
running: 0,
error: 0,
idle: 0,
};
const recentDeployments = useMemo(() => {
if (!deployments) return [];
return [...deployments]
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)
.slice(0, 10);
}, [deployments]);
const deployStats = useMemo(() => {
const now = Date.now();
const weekMs = 7 * 24 * 60 * 60 * 1000;
const lastStart = now - weekMs;
const prevStart = now - 2 * weekMs;
const last: NonNullable<typeof deployments> = [];
const prev: NonNullable<typeof deployments> = [];
for (const d of deployments ?? []) {
const t = new Date(d.createdAt).getTime();
if (t >= lastStart) last.push(d);
else if (t >= prevStart) prev.push(d);
}
const lastCount = last.length;
const prevCount = prev.length;
let delta: string | undefined;
if (prevCount > 0) {
const pct = Math.round(((lastCount - prevCount) / prevCount) * 100);
delta = `${pct >= 0 ? "+" : ""}${pct}% vs prev 7d`;
} else if (lastCount > 0) {
delta = "no prior data";
} else {
delta = "no activity yet";
}
return { value: String(lastCount), delta };
}, [deployments]);
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl min-h-[85vh]">
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-6 h-full">
<div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
<h1 className="text-3xl font-semibold tracking-tight">
{firstName ? `Welcome back, ${firstName}` : "Welcome back"}
</h1>
<Button asChild variant="secondary" className="w-fit">
<Link href="/dashboard/projects">
Go to projects
<ArrowRight className="size-4" />
</Link>
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Projects"
value={String(totals.projects)}
delta={`${totals.environments} ${totals.environments === 1 ? "environment" : "environments"}`}
/>
<StatCard
label="Services"
value={String(totals.services)}
delta={`${totals.applications} apps · ${totals.compose} compose · ${totals.databases} db`}
/>
<StatCard
label="Deploys / 7d"
value={deployStats.value}
delta={deployStats.delta}
/>
<StatusListCard
label="Status"
items={[
{
dotClass: "bg-emerald-500",
label: "running",
count: statusBreakdown.running,
},
{
dotClass: "bg-red-500",
label: "errored",
count: statusBreakdown.error,
},
{
dotClass: "bg-muted-foreground/40",
label: "idle",
count: statusBreakdown.idle,
},
]}
/>
</div>
<div className="rounded-xl border bg-background">
<div className="flex items-center justify-between px-5 py-4 border-b">
<div className="flex items-center gap-2">
<Rocket className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Recent deployments</h2>
</div>
{canReadDeployments && (
<Link
href="/dashboard/deployments"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
view all
</Link>
)}
</div>
{!canReadDeployments ? (
<div className="min-h-[400px] flex flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground p-10">
<Rocket className="size-8 opacity-40" />
<span>You do not have permission to view deployments.</span>
</div>
) : recentDeployments.length === 0 ? (
<div className="min-h-[400px] flex flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground p-10">
<Rocket className="size-8 opacity-40" />
<span>No deployments yet.</span>
</div>
) : (
<ul className="divide-y">
{recentDeployments.map((d) => {
const info = getServiceInfo(d);
if (!info) return null;
const status = (d.status ?? "idle") as DeploymentStatus;
return (
<li key={d.deploymentId}>
<Link
href={info.href}
className="flex items-center gap-4 px-5 py-4 hover:bg-muted/40 transition-colors"
>
<span
className={`size-2 rounded-full shrink-0 ${statusDotClass[status] ?? statusDotClass.idle}`}
aria-hidden
/>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm truncate">{info.name}</span>
<span className="text-xs text-muted-foreground truncate">
{info.projectName} · {info.environment}
</span>
</div>
<span className="text-xs text-muted-foreground w-36 hidden lg:flex items-center justify-end gap-1.5 truncate">
<Server className="size-3 shrink-0" />
<span className="truncate">{info.serverName}</span>
</span>
<span className="text-xs text-muted-foreground w-20 text-right hidden sm:inline">
{status}
</span>
<span className="text-xs text-muted-foreground w-24 text-right hidden md:inline">
{formatDistanceToNow(new Date(d.createdAt), {
addSuffix: true,
})}
</span>
<span className="text-xs text-muted-foreground hover:text-foreground transition-colors">
logs
</span>
</Link>
</li>
);
})}
</ul>
)}
</div>
</div>
</Card>
</div>
);
};

View File

@@ -1,10 +1,10 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props {
mariadbId: string;

View File

@@ -1,10 +1,10 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props {
mongoId: string;

View File

@@ -1,10 +1,10 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props {
mysqlId: string;

View File

@@ -1,10 +1,10 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props {
postgresId: string;

View File

@@ -632,6 +632,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
control={form.control}
name="enableNamespaces"
render={({ field }) => {
console.log(field.value);
return (
<FormItem>
<FormLabel>Enable Namespaces</FormLabel>

View File

@@ -1,494 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Code2, FileInput, Globe2, HardDrive, HelpCircle } from "lucide-react";
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 { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { slugify } from "@/lib/slug";
import { api } from "@/utils/api";
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
const AddImportSchema = z.object({
name: z.string().min(1, { message: "Name is required" }),
appName: z
.string()
.min(1, { message: "App name is required" })
.regex(APP_NAME_REGEX, { message: APP_NAME_MESSAGE }),
base64: z.string().min(1, { message: "Base64 content is required" }),
serverId: z.string().optional(),
});
type AddImport = z.infer<typeof AddImportSchema>;
type TemplateInfo = {
compose: string;
template: {
domains: Array<{
serviceName: string;
port: number;
path?: string;
host?: string;
}>;
envs: string[];
mounts: Array<{ filePath: string; content: string }>;
};
};
interface Props {
environmentId: string;
projectName?: string;
}
export const AddImport = ({ environmentId, projectName }: Props) => {
const utils = api.useUtils();
const [visible, setVisible] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const [mountOpen, setMountOpen] = useState(false);
const [selectedMount, setSelectedMount] = useState<{
filePath: string;
content: string;
} | null>(null);
const [templateInfo, setTemplateInfo] = useState<TemplateInfo | null>(null);
const slug = slugify(projectName);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery();
const shouldShowServerDropdown = !!(servers && servers.length > 0);
const { mutateAsync: previewTemplate, isPending: isProcessing } =
api.compose.previewTemplate.useMutation();
const { mutateAsync: createCompose, isPending: isCreating } =
api.compose.create.useMutation();
const { mutateAsync: importCompose, isPending: isImporting } =
api.compose.import.useMutation();
const form = useForm<AddImport>({
defaultValues: { name: "", appName: `${slug}-`, base64: "" },
resolver: zodResolver(AddImportSchema),
});
const resetAll = () => {
form.reset({ name: "", appName: `${slug}-`, base64: "" });
setTemplateInfo(null);
setPreviewOpen(false);
setMountOpen(false);
setSelectedMount(null);
};
const handleOpenChange = (open: boolean) => {
if (!open) resetAll();
setVisible(open);
};
const handleLoad = async (data: AddImport) => {
try {
const result = await previewTemplate({
appName: data.appName,
base64: data.base64.trim(),
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
});
setTemplateInfo(result);
setPreviewOpen(true);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error processing template",
);
}
};
const handleImport = async () => {
const data = form.getValues();
try {
const compose = await createCompose({
name: data.name,
appName: data.appName,
environmentId,
composeType: "docker-compose",
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
});
await importCompose({
composeId: compose.composeId,
base64: data.base64.trim(),
});
toast.success("Compose imported successfully");
await utils.environment.one.invalidate({ environmentId });
resetAll();
setVisible(false);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error importing compose",
);
}
};
const handleCancelPreview = () => {
setPreviewOpen(false);
setTemplateInfo(null);
};
return (
<>
<Dialog open={visible} onOpenChange={handleOpenChange}>
<DialogTrigger className="w-full">
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
<FileInput className="size-4 text-muted-foreground" />
<span>Import</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Import Compose</DialogTitle>
<DialogDescription>
Paste a base64-encoded compose export to preview and import it
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
id="hook-form-import"
onSubmit={form.handleSubmit(handleLoad)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="My App"
{...field}
onChange={(e) => {
const val = e.target.value || "";
form.setValue(
"appName",
`${slug}-${slugify(val.trim())}`,
);
field.onChange(val);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{shouldShowServerDropdown && (
<FormField
control={form.control}
name="serverId"
render={({ field }) => (
<FormItem>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
Select a Server {!isCloud ? "(Optional)" : ""}
<HelpCircle className="size-4 text-muted-foreground" />
</FormLabel>
</TooltipTrigger>
<TooltipContent
className="z-[999] w-[300px]"
align="start"
side="top"
>
<span>
If no server is selected, the compose will be
deployed on the server where the user is logged
in.
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Select
onValueChange={field.onChange}
defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
}
>
<SelectTrigger>
<SelectValue
placeholder={
!isCloud ? "Dokploy" : "Select a Server"
}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{!isCloud && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
<span className="text-muted-foreground text-xs self-center">
Default
</span>
</span>
</SelectItem>
)}
{servers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
<span className="flex items-center gap-2 justify-between w-full">
<span>{server.name}</span>
<span className="text-muted-foreground text-xs self-center">
{server.ipAddress}
</span>
</span>
</SelectItem>
))}
<SelectLabel>
Servers (
{(servers?.length ?? 0) + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="appName"
render={({ field }) => (
<FormItem>
<FormLabel>App Name</FormLabel>
<FormControl>
<Input placeholder="my-app" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="base64"
render={({ field }) => (
<FormItem>
<FormLabel>Configuration (Base64)</FormLabel>
<FormControl>
<Textarea
placeholder="Paste your base64-encoded compose export here..."
className="font-mono resize-none h-32"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
variant="outline"
isLoading={isCreating || isProcessing}
>
Load
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
{/* Preview modal */}
<Dialog
open={previewOpen}
onOpenChange={(open) => !open && handleCancelPreview()}
>
<DialogContent className="max-w-[60vw]">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
Template Information
</DialogTitle>
<DialogDescription className="space-y-2">
<p>Review the template information before importing</p>
<AlertBlock type="warning">
Warning: This will remove all existing environment variables,
mounts, and domains from this service.
</AlertBlock>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-6">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Code2 className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Docker Compose</h3>
</div>
<CodeEditor
language="yaml"
value={templateInfo?.compose || ""}
className="font-mono"
readOnly
/>
</div>
{templateInfo?.template.domains &&
templateInfo.template.domains.length > 0 && (
<>
<Separator />
<div className="space-y-4">
<div className="flex items-center gap-2">
<Globe2 className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Domains</h3>
</div>
<div className="grid grid-cols-1 gap-3">
{templateInfo.template.domains.map((domain, index) => (
<div
key={index}
className="rounded-lg border bg-card p-3 text-card-foreground shadow-sm"
>
<div className="font-medium">
{domain.serviceName}
</div>
<div className="text-sm text-muted-foreground space-y-1">
<div>Port: {domain.port}</div>
{domain.host && <div>Host: {domain.host}</div>}
{domain.path && <div>Path: {domain.path}</div>}
</div>
</div>
))}
</div>
</div>
</>
)}
{templateInfo?.template.envs &&
templateInfo.template.envs.length > 0 && (
<>
<Separator />
<div className="space-y-4">
<div className="flex items-center gap-2">
<Code2 className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">
Environment Variables
</h3>
</div>
<div className="grid grid-cols-1 gap-2">
{templateInfo.template.envs.map((env, index) => (
<div
key={index}
className="rounded-lg truncate border bg-card p-2 font-mono text-sm"
>
{env}
</div>
))}
</div>
</div>
</>
)}
{templateInfo?.template.mounts &&
templateInfo.template.mounts.length > 0 && (
<>
<Separator />
<div className="space-y-4">
<div className="flex items-center gap-2">
<HardDrive className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Mounts</h3>
</div>
<div className="grid grid-cols-1 gap-2">
{templateInfo.template.mounts.map((mount, index) => (
<div
key={index}
className="rounded-lg border bg-card p-2 font-mono text-sm hover:bg-accent cursor-pointer transition-colors"
onClick={() => {
setSelectedMount(mount);
setMountOpen(true);
}}
>
{mount.filePath}
</div>
))}
</div>
</div>
</>
)}
</div>
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={handleCancelPreview}>
Cancel
</Button>
<Button isLoading={isImporting} onClick={handleImport}>
Import
</Button>
</div>
</DialogContent>
</Dialog>
{/* Mount content modal */}
<Dialog open={mountOpen} onOpenChange={setMountOpen}>
<DialogContent className="max-w-[50vw]">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
{selectedMount?.filePath}
</DialogTitle>
<DialogDescription>Mount File Content</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[45vh] pr-4">
<CodeEditor
language="yaml"
value={selectedMount?.content || ""}
className="font-mono"
readOnly
/>
</ScrollArea>
<div className="flex justify-end gap-2 pt-4">
<Button onClick={() => setMountOpen(false)}>Close</Button>
</div>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -1,6 +1,6 @@
import {
Bookmark,
BookText,
Bookmark,
CheckIcon,
ChevronsUpDown,
Globe,

View File

@@ -166,7 +166,6 @@ export const ShowProjects = () => {
return (
total +
(env.applications?.length || 0) +
(env.libsql?.length || 0) +
(env.mariadb?.length || 0) +
(env.mongo?.length || 0) +
(env.mysql?.length || 0) +
@@ -179,7 +178,6 @@ export const ShowProjects = () => {
return (
total +
(env.applications?.length || 0) +
(env.libsql?.length || 0) +
(env.mariadb?.length || 0) +
(env.mongo?.length || 0) +
(env.mysql?.length || 0) +
@@ -344,7 +342,7 @@ export const ShowProjects = () => {
}
}}
>
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border flex flex-col">
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2 overflow-clip">
<span className="flex flex-col gap-1.5 ">
@@ -491,7 +489,7 @@ export const ShowProjects = () => {
</div>
</CardTitle>
</CardHeader>
<CardFooter className="pt-4 mt-auto">
<CardFooter className="pt-4">
<div className="space-y-1 text-xs flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4">
<DateTooltip date={project.createdAt}>
Created

View File

@@ -1,10 +1,10 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props {
redisId: string;

View File

@@ -79,11 +79,8 @@ export const columns: ColumnDef<LogEntry>[] = [
: log.RequestPath}
</div>
<div className="flex flex-row gap-3 w-full">
<Badge
variant={getStatusColor(log.OriginStatus || log.DownstreamStatus)}
>
Status:{" "}
{formatStatusLabel(log.OriginStatus || log.DownstreamStatus)}
<Badge variant={getStatusColor(log.OriginStatus)}>
Status: {formatStatusLabel(log.OriginStatus)}
</Badge>
<Badge variant={"secondary"}>
Exec Time: {formatDuration(log.Duration)}

View File

@@ -185,7 +185,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by hostname..."
placeholder="Filter by name..."
value={search}
onChange={(event) => setSearch(event.target.value)}
className="md:max-w-sm"

View File

@@ -167,7 +167,7 @@ export const SearchCommand = () => {
<CommandGroup heading={"Application"} hidden={true}>
<CommandItem
onSelect={() => {
router.push("/dashboard/home");
router.push("/dashboard/projects");
setOpen(false);
}}
>

View File

@@ -1,4 +1,4 @@
import { CreditCard, FileText, Server } from "lucide-react";
import { CreditCard, FileText } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import {
@@ -17,11 +17,6 @@ const navigationItems = [
href: "/dashboard/settings/billing",
icon: CreditCard,
},
{
name: "Managed Servers",
href: "/dashboard/settings/managed-servers",
icon: Server,
},
{
name: "Invoices",
href: "/dashboard/settings/invoices",

View File

@@ -9,7 +9,6 @@ import {
Loader2,
MinusIcon,
PlusIcon,
Server,
ShieldCheck,
} from "lucide-react";
import Link from "next/link";
@@ -26,6 +25,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { NumberInput } from "@/components/ui/input";
import {
Dialog,
DialogContent,
@@ -34,7 +34,6 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { NumberInput } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { Switch } from "@/components/ui/switch";
@@ -83,11 +82,6 @@ const navigationItems = [
href: "/dashboard/settings/billing",
icon: CreditCard,
},
{
name: "Managed Servers",
href: "/dashboard/settings/managed-servers",
icon: Server,
},
{
name: "Invoices",
href: "/dashboard/settings/invoices",

View File

@@ -1,493 +0,0 @@
import {
AlertCircle,
CheckCircle2,
Clock,
CreditCard,
ExternalLink,
FileText,
Loader2,
Plus,
Server,
Trash2,
XCircle,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
const navigationItems = [
{
name: "Subscription",
href: "/dashboard/settings/billing",
icon: CreditCard,
},
{
name: "Managed Servers",
href: "/dashboard/settings/managed-servers",
icon: Server,
},
{
name: "Invoices",
href: "/dashboard/settings/invoices",
icon: FileText,
},
];
const STATUS_MAP: Record<
string,
{
label: string;
icon: React.ReactNode;
variant: "default" | "secondary" | "destructive" | "outline";
}
> = {
pending: {
label: "Pending",
icon: <Clock className="size-3" />,
variant: "secondary",
},
provisioning: {
label: "Provisioning",
icon: <Loader2 className="size-3 animate-spin" />,
variant: "secondary",
},
configuring: {
label: "Installing Dokploy",
icon: <Loader2 className="size-3 animate-spin" />,
variant: "secondary",
},
ready: {
label: "Ready",
icon: <CheckCircle2 className="size-3" />,
variant: "default",
},
error: {
label: "Error",
icon: <XCircle className="size-3" />,
variant: "destructive",
},
terminating: {
label: "Terminating",
icon: <Loader2 className="size-3 animate-spin" />,
variant: "secondary",
},
terminated: {
label: "Terminated",
icon: <AlertCircle className="size-3" />,
variant: "outline",
},
};
function formatSpecs(cpus: number, memoryMb: number, diskMb: number, bandwidthMb: number) {
const bandwidthTb = bandwidthMb / 1024 / 1024;
const bandwidthLabel = bandwidthTb >= 1 ? `${bandwidthTb.toFixed(0)} TB` : `${Math.round(bandwidthMb / 1024)} GB`;
return `${cpus} vCPU · ${Math.round(memoryMb / 1024)} GB RAM · ${Math.round(diskMb / 1024)} GB NVMe · ${bandwidthLabel} bandwidth`;
}
function centsToDisplay(cents: number) {
return (cents / 100).toFixed(2).replace(/\.00$/, "");
}
function OrderServerDialog({ onSuccess }: { onSuccess: () => void }) {
const [open, setOpen] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<string>("");
const [selectedDc, setSelectedDc] = useState<string>("");
const [isAnnual, setIsAnnual] = useState(false);
const { data: plans, isLoading: loadingPlans } =
api.managedServer.getPlans.useQuery(undefined, { enabled: open });
const { data: dataCenters, isLoading: loadingDcs } =
api.managedServer.getDataCenters.useQuery(undefined, { enabled: open });
const isLoadingOptions = loadingPlans || loadingDcs;
const purchase = api.managedServer.purchase.useMutation({
onSuccess: () => {
toast.success("Server order placed! Provisioning will take ~5 minutes.");
setOpen(false);
onSuccess();
},
onError: (err) => {
toast.error(err.message);
},
});
const plan = plans?.find((p) => p.id === selectedPlan);
const displayPrice = (p: NonNullable<typeof plan>) =>
isAnnual
? `$${centsToDisplay(p.dokployPriceCentsAnnual)}/yr`
: `$${centsToDisplay(p.dokployPriceCentsMonthly)}/mo`;
const displayPriceSmall = (p: NonNullable<typeof plan>) =>
isAnnual
? `$${centsToDisplay(Math.round(p.dokployPriceCentsAnnual / 12))}/mo billed annually`
: `$${centsToDisplay(p.dokployPriceCentsAnnual)}/yr if annual`;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="size-4 mr-2" />
Order Server
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Order a Managed Server</DialogTitle>
<DialogDescription>
We'll provision and configure a server for you automatically. Ready
in ~5 minutes.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 pt-2">
{isLoadingOptions ? (
<div className="flex flex-col items-center justify-center py-8 gap-3 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
<p className="text-sm">Loading available plans...</p>
</div>
) : (
<div className="space-y-4">
{/* Billing period toggle */}
<div className="flex items-center gap-1 rounded-lg border p-1 bg-muted/40 w-fit">
<button
type="button"
onClick={() => setIsAnnual(false)}
className={cn(
"px-4 py-1.5 rounded-md text-sm font-medium transition-colors",
!isAnnual
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
Monthly
</button>
<button
type="button"
onClick={() => setIsAnnual(true)}
className={cn(
"px-4 py-1.5 rounded-md text-sm font-medium transition-colors flex items-center gap-1.5",
isAnnual
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
Annual
<span className="text-xs bg-green-500/15 text-green-600 dark:text-green-400 px-1.5 py-0.5 rounded font-semibold">
Save ~20%
</span>
</button>
</div>
{/* Plan selector */}
<div className="space-y-2">
<Label>Plan</Label>
<div className="grid gap-2">
{plans?.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setSelectedPlan(p.id)}
className={cn(
"flex items-center justify-between rounded-lg border p-3 text-left transition-colors",
selectedPlan === p.id
? "border-primary bg-primary/5"
: "border-border hover:border-muted-foreground",
)}
>
<div>
<p className="font-medium text-sm">{p.name}</p>
<p className="text-xs text-muted-foreground">
{formatSpecs(p.cpus, p.memoryMb, p.diskMb, p.bandwidthMb)}
</p>
</div>
<div className="text-right">
<p className="font-semibold text-sm">
{displayPrice(p)}
</p>
<p className="text-xs text-muted-foreground">
{displayPriceSmall(p)}
</p>
</div>
</button>
))}
</div>
</div>
{/* Data center selector */}
<div className="space-y-2">
<Label>Data Center</Label>
<Select value={selectedDc} onValueChange={setSelectedDc}>
<SelectTrigger>
<SelectValue placeholder="Select a location..." />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4} className="max-h-56 overflow-y-auto">
{dataCenters?.map((dc) => (
<SelectItem key={dc.id} value={String(dc.id)}>
{dc.city} — {dc.continent}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{plan && selectedDc && (
<div className="rounded-lg bg-muted p-3 text-sm space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">Plan</span>
<span className="font-medium">{plan.name}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Billing</span>
<span className="font-medium">{isAnnual ? "Annual" : "Monthly"}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Total</span>
<span className="font-semibold">{displayPrice(plan)}</span>
</div>
</div>
)}
<Button
className="w-full"
disabled={!selectedPlan || !selectedDc || purchase.isPending}
onClick={() => {
if (!selectedPlan || !selectedDc) return;
purchase.mutate({
plan: selectedPlan,
dataCenterId: Number(selectedDc),
isAnnual,
});
}}
>
{purchase.isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Placing order...
</>
) : (
"Order Server"
)}
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
export const ShowManagedServers = () => {
const router = useRouter();
const utils = api.useUtils();
const { data: servers, isLoading } = api.managedServer.list.useQuery();
const syncStatus = api.managedServer.syncStatus.useMutation({
onSuccess: () => utils.managedServer.list.invalidate(),
});
const deleteServer = api.managedServer.delete.useMutation({
onSuccess: () => {
toast.success("Server terminated.");
utils.managedServer.list.invalidate();
},
onError: (err) => toast.error(err.message),
});
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">
<Server className="size-6 text-muted-foreground self-center" />
Billing
</CardTitle>
<CardDescription>
Manage your subscription and servers
</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 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-base">Managed Servers</h3>
<p className="text-sm text-muted-foreground">
Servers provisioned and managed by Dokploy Cloud
</p>
</div>
<OrderServerDialog
onSuccess={() => utils.managedServer.list.invalidate()}
/>
</div>
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : servers?.length === 0 ? (
<div className="text-center py-12 border rounded-lg border-dashed">
<Server className="size-10 mx-auto text-muted-foreground mb-3" />
<p className="text-sm font-medium">No managed servers yet</p>
<p className="text-xs text-muted-foreground mt-1">
Order a server and we'll provision and configure it for you
automatically.
</p>
</div>
) : (
<div className="space-y-3">
{servers?.map((s) => {
const status =
STATUS_MAP[s.status] ?? STATUS_MAP.error!;
const isProvisioning = [
"pending",
"provisioning",
"configuring",
].includes(s.status);
const planLabel = s.plan
.split("-")
.slice(-2)
.join(" ")
.toUpperCase();
return (
<div
key={s.managedServerId}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex items-center gap-3">
<Server className="size-5 text-muted-foreground shrink-0" />
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">
{planLabel}
</span>
<Badge
variant={status?.variant}
className="flex items-center gap-1 text-xs h-5"
>
{status?.icon}
{status?.label}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{s.hostname ?? ""}
{s.ipAddress ? ` · ${s.ipAddress}` : ""}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{isProvisioning && (
<Button
variant="ghost"
size="sm"
onClick={() =>
syncStatus.mutate({
managedServerId: s.managedServerId,
})
}
disabled={syncStatus.isPending}
>
<Loader2
className={cn(
"size-4",
syncStatus.isPending && "animate-spin",
)}
/>
</Button>
)}
{s.status === "ready" && s.server && (
<Button variant="outline" size="sm" asChild>
<Link
href={`/dashboard/settings/server?serverId=${s.serverId}`}
>
<ExternalLink className="size-3.5 mr-1.5" />
Open
</Link>
</Button>
)}
<DialogAction
title="Terminate Server"
description="This will permanently destroy the server and all data on it. This action cannot be undone."
type="destructive"
onClick={() =>
deleteServer.mutate({
managedServerId: s.managedServerId,
})
}
>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
>
<Trash2 className="size-4" />
</Button>
</DialogAction>
</div>
</div>
);
})}
</div>
)}
</div>
</CardContent>
</div>
</Card>
</div>
);
};

View File

@@ -1,5 +1,3 @@
import { HelpCircle } from "lucide-react";
import { toast } from "sonner";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
@@ -9,6 +7,8 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { HelpCircle } from "lucide-react";
import { toast } from "sonner";
interface Props {
serverId?: string;

View File

@@ -425,7 +425,7 @@ export const WelcomeSubscription = () => {
onClick={() => {
if (stepper.isLast) {
setIsOpen(false);
push("/dashboard/home");
push("/dashboard/projects");
} else {
stepper.next();
}

View File

@@ -3,7 +3,6 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { EnterpriseFeatureLocked } from "@/components/proprietary/enterprise-feature-gate";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -27,6 +26,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { EnterpriseFeatureLocked } from "@/components/proprietary/enterprise-feature-gate";
import { api, type RouterOutputs } from "@/utils/api";
/** Shape returned by project.allForPermissions (admin only). Used for the permissions UI. */

View File

@@ -141,14 +141,14 @@ export const WebDomain = () => {
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 grid-cols-2"
className="grid w-full gap-4 md:grid-cols-2"
>
<FormField
control={form.control}
name="domain"
render={({ field }) => {
return (
<FormItem className="col-span-2 md:col-span-1">
<FormItem>
<FormLabel>Domain</FormLabel>
<FormControl>
<Input
@@ -168,7 +168,7 @@ export const WebDomain = () => {
name="letsEncryptEmail"
render={({ field }) => {
return (
<FormItem className="col-span-2 md:col-span-1">
<FormItem>
<FormLabel>Let's Encrypt Email</FormLabel>
<FormControl>
<Input
@@ -209,7 +209,7 @@ export const WebDomain = () => {
name="certificateType"
render={({ field }) => {
return (
<FormItem className="col-span-2">
<FormItem className="md:col-span-2">
<FormLabel>Certificate Provider</FormLabel>
<Select
onValueChange={field.onChange}

View File

@@ -1,4 +1,4 @@
import { CopyIcon, ServerIcon } from "lucide-react";
import { ServerIcon } from "lucide-react";
import {
Card,
CardContent,
@@ -7,8 +7,6 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import copy from "copy-to-clipboard";
import { toast } from "sonner";
import { ShowDokployActions } from "./servers/actions/show-dokploy-actions";
import { ShowStorageActions } from "./servers/actions/show-storage-actions";
import { ShowTraefikActions } from "./servers/actions/show-traefik-actions";
@@ -51,17 +49,8 @@ export const WebServer = () => {
</div>
<div className="flex items-center flex-wrap justify-between gap-4">
<span className="text-sm text-muted-foreground flex items-center gap-1.5">
<span className="text-sm text-muted-foreground">
Server IP: {webServerSettings?.serverIp}
{webServerSettings?.serverIp && (
<CopyIcon
className="size-3.5 cursor-pointer hover:text-foreground transition-colors"
onClick={() => {
copy(webServerSettings.serverIp ?? "");
toast.success("Copied to clipboard");
}}
/>
)}
</span>
<span className="text-sm text-muted-foreground">
Version: {dokployVersion}

View File

@@ -3,13 +3,15 @@ import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes
import { ShowCustomCommand } from "@/components/dashboard/postgres/advanced/show-custom-command";
import { ShowClusterSettings } from "../application/advanced/cluster/show-cluster-settings";
import { RebuildDatabase } from "./rebuild-database";
import { TransferService } from "./transfer-service";
interface Props {
id: string;
type: "libsql" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis";
serverId?: string | null;
}
export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => {
export const ShowDatabaseAdvancedSettings = ({ id, type, serverId }: Props) => {
return (
<div className="flex w-full flex-col gap-5">
<ShowCustomCommand id={id} type={type} />
@@ -23,6 +25,13 @@ export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => {
<ShowVolumes id={id} type={type} />
<ShowResources id={id} type={type} />
<RebuildDatabase id={id} type={type} />
{type !== "libsql" && (
<TransferService
serviceId={id}
serviceType={type}
currentServerId={serverId ?? null}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,596 @@
import {
AlertTriangle,
ArrowRightLeft,
Loader2,
Server,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DrawerLogs } from "@/components/shared/drawer-logs";
import type { LogLine } from "@/components/dashboard/docker/logs/utils";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
type ServiceType =
| "application"
| "compose"
| "postgres"
| "mysql"
| "mariadb"
| "mongo"
| "redis";
interface TransferServiceProps {
serviceId: string;
serviceType: ServiceType;
currentServerId: string | null;
}
interface ScanResult {
serviceDirectory: {
files: Array<{
path: string;
status: string;
sourceFile: { path: string; size: number; modifiedAt: number };
targetFile?: { path: string; size: number; modifiedAt: number };
}>;
totalSize: number;
};
traefikConfig: {
exists: boolean;
hasConflict: boolean;
};
mounts: Array<{
mount: {
mountId: string;
type: string;
volumeName?: string | null;
hostPath?: string | null;
mountPath: string;
};
files: Array<{
path: string;
status: string;
}>;
totalSize: number;
}>;
totalTransferSize: number;
totalFiles: number;
conflicts: Array<{
path: string;
status: string;
}>;
}
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
};
const useScanMutation = (serviceType: ServiceType) => {
const mutations = {
application: api.application.transferScan.useMutation(),
compose: api.compose.transferScan.useMutation(),
postgres: api.postgres.transferScan.useMutation(),
mysql: api.mysql.transferScan.useMutation(),
mariadb: api.mariadb.transferScan.useMutation(),
mongo: api.mongo.transferScan.useMutation(),
redis: api.redis.transferScan.useMutation(),
};
return mutations[serviceType];
};
const getServiceIdKey = (serviceType: ServiceType): string => {
const map: Record<ServiceType, string> = {
application: "applicationId",
compose: "composeId",
postgres: "postgresId",
mysql: "mysqlId",
mariadb: "mariadbId",
mongo: "mongoId",
redis: "redisId",
};
return map[serviceType];
};
export const TransferService = ({
serviceId,
serviceType,
currentServerId,
}: TransferServiceProps) => {
const [targetServerId, setTargetServerId] = useState<string>("");
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [step, setStep] = useState<"select" | "scan" | "confirm">("select");
const [showConfirm, setShowConfirm] = useState(false);
// Drawer logs state
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [isTransferring, setIsTransferring] = useState(false);
const { data: servers } = api.server.all.useQuery();
const utils = api.useUtils();
const scan = useScanMutation(serviceType);
const idKey = getServiceIdKey(serviceType);
const availableServers = servers?.filter(
(s) => s.serverId !== currentServerId,
);
const selectedServer = servers?.find((s) => s.serverId === targetServerId);
// Subscription for transfer with logs
const subscriptionInput = {
[idKey]: serviceId,
targetServerId: targetServerId || "placeholder",
decisions: {},
};
const useTransferSubscription = (sType: ServiceType) => {
api.application.transferWithLogs.useSubscription(subscriptionInput as any, {
enabled: isTransferring && sType === "application",
onData: handleLogData,
onError: handleLogError,
});
api.compose.transferWithLogs.useSubscription(subscriptionInput as any, {
enabled: isTransferring && sType === "compose",
onData: handleLogData,
onError: handleLogError,
});
api.postgres.transferWithLogs.useSubscription(subscriptionInput as any, {
enabled: isTransferring && sType === "postgres",
onData: handleLogData,
onError: handleLogError,
});
api.mysql.transferWithLogs.useSubscription(subscriptionInput as any, {
enabled: isTransferring && sType === "mysql",
onData: handleLogData,
onError: handleLogError,
});
api.mariadb.transferWithLogs.useSubscription(subscriptionInput as any, {
enabled: isTransferring && sType === "mariadb",
onData: handleLogData,
onError: handleLogError,
});
api.mongo.transferWithLogs.useSubscription(subscriptionInput as any, {
enabled: isTransferring && sType === "mongo",
onData: handleLogData,
onError: handleLogError,
});
api.redis.transferWithLogs.useSubscription(subscriptionInput as any, {
enabled: isTransferring && sType === "redis",
onData: handleLogData,
onError: handleLogError,
});
};
const handleLogData = (log: string) => {
if (!isDrawerOpen) {
setIsDrawerOpen(true);
}
// Try to parse as JSON progress
try {
const progress = JSON.parse(log);
if (progress.message) {
const logLine: LogLine = {
rawTimestamp: new Date().toISOString(),
timestamp: new Date(),
message: `[${progress.phase || "transfer"}] ${progress.message}`,
};
setFilteredLogs((prev) => [...prev, logLine]);
}
return;
} catch {
// Not JSON, treat as plain text
}
const logLine: LogLine = {
rawTimestamp: new Date().toISOString(),
timestamp: new Date(),
message: log,
};
setFilteredLogs((prev) => [...prev, logLine]);
if (
log.includes("completed successfully") ||
log.includes("Deployment queued") ||
log.includes("Deployment started")
) {
setTimeout(() => {
setIsTransferring(false);
utils.invalidate();
toast.success("Transfer and deployment completed!");
}, 2000);
}
if (log.includes("Transfer failed") || log.includes("Transfer error")) {
setIsTransferring(false);
toast.error("Transfer failed");
}
};
const handleLogError = (error: unknown) => {
console.error("Transfer subscription error:", error);
setIsTransferring(false);
const logLine: LogLine = {
rawTimestamp: new Date().toISOString(),
timestamp: new Date(),
message: `Error: ${error instanceof Error ? error.message : String(error)}`,
};
setFilteredLogs((prev) => [...prev, logLine]);
};
// Register the subscription hooks (must be called unconditionally)
useTransferSubscription(serviceType);
const handleScan = async () => {
if (!targetServerId) {
toast.error("Please select a target server");
return;
}
setStep("scan");
try {
const result = await scan.mutateAsync({
[idKey]: serviceId,
targetServerId,
} as any);
setScanResult(result as ScanResult);
setStep("confirm");
} catch (error) {
toast.error(
`Scan failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
setStep("select");
}
};
const handleTransfer = async () => {
setShowConfirm(false);
setFilteredLogs([]);
setIsTransferring(true);
setIsDrawerOpen(true);
// Add initial log
setFilteredLogs([
{
rawTimestamp: new Date().toISOString(),
timestamp: new Date(),
message: `Starting transfer to ${selectedServer?.name} (${selectedServer?.ipAddress})...`,
},
]);
};
const isDbService = [
"postgres",
"mysql",
"mariadb",
"mongo",
"redis",
].includes(serviceType);
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
<ArrowRightLeft className="size-5" />
Transfer Service
</CardTitle>
<CardDescription>
Transfer this {serviceType} service to a different server. Source data
is never modified or deleted.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!availableServers?.length ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Server className="size-4" />
<span>
No other servers available. Add a remote server first.
</span>
</div>
) : (
<>
{/* Step 1: Select target server */}
<div className="space-y-2">
<span className="text-sm font-medium">Target Server</span>
<Select
value={targetServerId}
onValueChange={(value) => {
setTargetServerId(value);
setScanResult(null);
setStep("select");
}}
disabled={isTransferring}
>
<SelectTrigger>
<SelectValue placeholder="Select target server" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{availableServers.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
<span className="flex items-center gap-2">
<span>{server.name}</span>
<span className="text-muted-foreground text-xs">
{server.ipAddress}
</span>
</span>
</SelectItem>
))}
<SelectLabel>
Servers ({availableServers.length})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</div>
{/* Scan button */}
{step === "select" && targetServerId && (
<Button
onClick={handleScan}
disabled={scan.isPending}
variant="outline"
>
{scan.isPending ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Scanning...
</>
) : (
"Scan for Transfer"
)}
</Button>
)}
{/* Step 2: Scan in progress */}
{step === "scan" && (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>
Scanning source and target servers for files and
conflicts...
</span>
</div>
)}
{/* Step 3: Scan results + confirm */}
{step === "confirm" && scanResult && (
<div className="space-y-4">
<div className="rounded-lg border p-4 space-y-3">
<h4 className="font-medium">Scan Results</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">
Total Files:
</span>{" "}
<span className="font-medium">
{scanResult.totalFiles}
</span>
</div>
<div>
<span className="text-muted-foreground">
Transfer Size:
</span>{" "}
<span className="font-medium">
{formatBytes(scanResult.totalTransferSize)}
</span>
</div>
<div>
<span className="text-muted-foreground">
Volumes/Mounts:
</span>{" "}
<span className="font-medium">
{scanResult.mounts.length}
</span>
</div>
<div>
<span className="text-muted-foreground">
Conflicts:
</span>{" "}
<Badge
variant={
scanResult.conflicts.length > 0
? "destructive"
: "secondary"
}
>
{scanResult.conflicts.length}
</Badge>
</div>
</div>
{scanResult.traefikConfig.exists && (
<div className="text-sm">
<span className="text-muted-foreground">
Traefik Config:
</span>{" "}
<Badge variant="outline">Will be synced</Badge>
</div>
)}
{scanResult.mounts.length > 0 && (
<div className="space-y-1">
<span className="text-sm text-muted-foreground">
Docker Volumes:
</span>
<div className="flex flex-wrap gap-1.5">
{scanResult.mounts.map((m) => (
<Badge
key={m.mount.mountId}
variant="outline"
className="font-mono text-xs"
>
{m.mount.volumeName ||
m.mount.hostPath ||
m.mount.mountPath}
{m.totalSize > 0 && (
<span className="ml-1 text-muted-foreground">
({formatBytes(m.totalSize)})
</span>
)}
{m.files.length > 0 && (
<span className="ml-1 text-muted-foreground">
{m.files.length} files
</span>
)}
</Badge>
))}
</div>
</div>
)}
</div>
{/* Conflict list */}
{scanResult.conflicts.length > 0 && (
<div className="rounded-lg border p-4 space-y-2">
<h4 className="font-medium text-sm">
File Conflicts (will be overwritten)
</h4>
<div className="max-h-40 overflow-y-auto space-y-1">
{scanResult.conflicts.map((conflict) => (
<div
key={conflict.path}
className="text-xs font-mono flex items-center gap-2"
>
<Badge
variant="outline"
className="text-[10px]"
>
{conflict.status}
</Badge>
<span className="truncate">
{conflict.path}
</span>
</div>
))}
</div>
</div>
)}
{/* Warning */}
<div className="rounded-lg border border-yellow-500/50 bg-yellow-500/10 p-4 space-y-2">
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
<AlertTriangle className="size-4" />
<span className="font-medium text-sm">
Service Downtime Warning
</span>
</div>
<p className="text-sm text-muted-foreground">
{isDbService
? "Stop the database service before transferring to avoid data corruption. After transfer completes, the service will be automatically deployed on the target server."
: "The service will be unavailable during transfer. After transfer completes, the service will be automatically deployed on the target server."}
</p>
</div>
{/* Transfer button */}
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
setStep("select");
setScanResult(null);
}}
>
Cancel
</Button>
<Button
onClick={() => setShowConfirm(true)}
disabled={isTransferring}
>
<ArrowRightLeft className="mr-2 size-4" />
Transfer to {selectedServer?.name}
</Button>
</div>
</div>
)}
</>
)}
{/* Confirmation dialog */}
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Service Transfer</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<p>
You are about to transfer this {serviceType} to{" "}
<strong>{selectedServer?.name}</strong> (
{selectedServer?.ipAddress}).
</p>
{scanResult && (
<p>
{scanResult.totalFiles} files (
{formatBytes(scanResult.totalTransferSize)}) will be
copied.
{scanResult.mounts.length > 0 &&
` ${scanResult.mounts.length} volume(s) will be transferred.`}
</p>
)}
<p className="text-yellow-600 dark:text-yellow-400 font-medium">
The service will experience downtime during this
process. After transfer, the service will be
automatically deployed on the target server.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleTransfer}>
Confirm Transfer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Drawer for transfer logs */}
<DrawerLogs
isOpen={isDrawerOpen}
onClose={() => {
setIsDrawerOpen(false);
if (!isTransferring) {
setFilteredLogs([]);
setStep("select");
setScanResult(null);
}
}}
filteredLogs={filteredLogs}
/>
</CardContent>
</Card>
);
};

View File

@@ -19,7 +19,6 @@ import {
Forward,
GalleryVerticalEnd,
GitBranch,
House,
Key,
KeyRound,
Loader2,
@@ -149,12 +148,6 @@ type Menu = {
// The `isEnabled` function is called to determine if the item should be displayed
const MENU: Menu = {
home: [
{
isSingle: true,
title: "Home",
url: "/dashboard/home",
icon: House,
},
{
isSingle: true,
title: "Projects",
@@ -868,19 +861,6 @@ function SidebarLogo() {
);
}
function MobileCloser() {
const pathname = usePathname();
const { setOpenMobile, isMobile } = useSidebar();
useEffect(() => {
if (isMobile) {
setOpenMobile(false);
}
}, [pathname, isMobile, setOpenMobile]);
return null;
}
export default function Page({ children }: Props) {
const [defaultOpen, setDefaultOpen] = useState<boolean | undefined>(
undefined,
@@ -946,7 +926,6 @@ export default function Page({ children }: Props) {
} as React.CSSProperties
}
>
<MobileCloser />
<Sidebar collapsible="icon" variant="floating">
<SidebarHeader>
{/* <SidebarMenuButton

View File

@@ -80,7 +80,7 @@ export const UserNav = () => {
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
router.push("/dashboard/home");
router.push("/dashboard/projects");
}}
>
Projects

View File

@@ -44,7 +44,7 @@ export function SignInWithSSO({ children }: SignInWithSSOProps) {
try {
const { data, error } = await authClient.signIn.sso({
email: values.email,
callbackURL: "/dashboard/home",
callbackURL: "/dashboard/projects",
});
if (error) {
toast.error(error.message ?? "Failed to sign in with SSO");

View File

@@ -342,7 +342,7 @@ export const AdvanceBreadcrumb = () => {
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
>
<FolderInput className="size-4 text-muted-foreground" />
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
<span className="font-medium max-w-[150px] truncate">
{currentProject?.name || "Select Project"}
</span>
<ChevronDown className="size-4 text-muted-foreground" />
@@ -478,7 +478,7 @@ export const AdvanceBreadcrumb = () => {
aria-expanded={environmentOpen}
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
>
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
<span className="font-medium max-w-[150px] truncate">
{currentEnvironment?.name || "production"}
</span>
<ChevronDown className="size-4 text-muted-foreground" />
@@ -533,7 +533,7 @@ export const AdvanceBreadcrumb = () => {
)}
{projectEnvironments && projectEnvironments.length === 1 && (
<p className="text-sm font-normal ml-1 max-w-[50px] md:max-w-[150px] truncate">
<p className="text-sm font-normal ml-1">
{currentEnvironment?.name || "production"}
</p>
)}
@@ -551,7 +551,7 @@ export const AdvanceBreadcrumb = () => {
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
>
{getServiceIcon(currentService.type)}
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
<span className="font-medium max-w-[150px] truncate">
{currentService.name}
</span>
<ChevronDown className="size-4 text-muted-foreground" />
@@ -617,7 +617,7 @@ export const AdvanceBreadcrumb = () => {
<Button
variant="ghost"
size="icon"
className="size-7 ml-1 hidden md:flex"
className="size-7 ml-1"
onClick={() => {
router.push(
`/dashboard/project/${projectId}/environment/${environmentId}`,

View File

@@ -116,14 +116,6 @@ export function TagSelector({
<HandleTag />
</div>
</CommandEmpty>
{tags.length === 0 && (
<div className="flex flex-col items-center gap-2 py-4">
<span className="text-sm text-muted-foreground">
No tags created yet.
</span>
<HandleTag />
</div>
)}
<CommandGroup>
{tags.map((tag) => {
const isSelected = selectedTags.includes(tag.id);

View File

@@ -63,7 +63,6 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
className={cn(
buttonVariants({ variant, size, className }),
"flex gap-2",
className,
)}
ref={ref}
{...props}

View File

@@ -1 +0,0 @@
ALTER TABLE "schedule" ADD COLUMN "description" text;

View File

@@ -1,22 +0,0 @@
CREATE TYPE "public"."managedServerStatus" AS ENUM('pending', 'provisioning', 'configuring', 'ready', 'error', 'terminating', 'terminated');--> statement-breakpoint
CREATE TABLE "managed_server" (
"managedServerId" text PRIMARY KEY NOT NULL,
"organizationId" text NOT NULL,
"serverId" text,
"plan" text NOT NULL,
"status" "managedServerStatus" DEFAULT 'pending' NOT NULL,
"hostingerVmId" integer,
"hostingerSubscriptionId" text,
"dataCenterId" integer NOT NULL,
"ipAddress" text,
"hostname" text,
"stripeSubscriptionId" text,
"stripePriceId" text,
"rootPassword" text,
"errorMessage" text,
"createdAt" text NOT NULL,
"updatedAt" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE set null ON UPDATE no action;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1163,20 +1163,6 @@
"when": 1775845419261,
"tag": "0165_abnormal_greymalkin",
"breakpoints": true
},
{
"idx": 166,
"version": "7",
"when": 1778303519111,
"tag": "0166_nosy_slapstick",
"breakpoints": true
},
{
"idx": 167,
"version": "7",
"when": 1778657133470,
"tag": "0167_dizzy_solo",
"breakpoints": true
}
]
}

View File

@@ -28,7 +28,6 @@ try {
"wait-for-postgres": "wait-for-postgres.ts",
"reset-password": "reset-password.ts",
"reset-2fa": "reset-2fa.ts",
"migrate-auth-secret": "scripts/migrate-auth-secret.ts",
},
bundle: true,
platform: "node",

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.29.4",
"version": "v0.29.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -14,7 +14,6 @@
"wait-for-postgres-dev": "tsx -r dotenv/config wait-for-postgres.ts",
"reset-password": "node -r dotenv/config dist/reset-password.mjs",
"reset-2fa": "node -r dotenv/config dist/reset-2fa.mjs",
"migrate-auth-secret": "node -r dotenv/config dist/migrate-auth-secret.mjs",
"dev": "tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
"studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
"migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts",
@@ -127,7 +126,7 @@
"next-themes": "^0.2.1",
"nextjs-toploader": "^3.9.17",
"node-os-utils": "2.0.1",
"node-pty": "1.1.0",
"node-pty": "1.0.0",
"node-schedule": "2.1.1",
"nodemailer": "6.9.14",
"octokit": "3.1.2",
@@ -148,7 +147,7 @@
"shell-quote": "^1.8.1",
"slugify": "^1.6.6",
"sonner": "^1.7.4",
"ssh2": "~1.16.0",
"ssh2": "1.15.0",
"stripe": "17.2.0",
"superjson": "^2.2.2",
"swagger-ui-react": "^5.31.2",

View File

@@ -53,7 +53,7 @@ export default function Custom404({ statusCode, error }: Props) {
<div className="mt-5 flex flex-col justify-center items-center gap-2 sm:flex-row sm:gap-3">
<Link
href="/dashboard/home"
href="/dashboard/projects"
className={buttonVariants({
variant: "secondary",
className: "flex flex-row gap-2",

View File

@@ -12,15 +12,6 @@ import type { DeploymentJob } from "@/server/queues/queue-types";
import { myQueue } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy";
/**
* Log a webhook handler error server-side without leaking its shape to the HTTP
* response. Drizzle errors carry the raw SQL query, column list and parameters,
* so we never forward the error object to the client.
*/
export const logWebhookError = (context: string, error: unknown) => {
console.error(context, error);
};
/**
* Helper function to get package_version from registry_package events
*/
@@ -271,15 +262,14 @@ export default async function handler(
);
}
} catch (error) {
logWebhookError("Error deploying Application:", error);
res.status(400).json({ message: "Error deploying Application" });
res.status(400).json({ message: "Error deploying Application", error });
return;
}
res.status(200).json({ message: "Application deployed successfully" });
} catch (error) {
logWebhookError("Error deploying Application:", error);
res.status(400).json({ message: "Error deploying Application" });
console.log(error);
res.status(400).json({ message: "Error deploying Application", error });
}
}

View File

@@ -12,7 +12,6 @@ import {
extractCommittedPaths,
extractHash,
getProviderByHeader,
logWebhookError,
} from "../[refreshToken]";
export default async function handler(
@@ -196,14 +195,13 @@ export default async function handler(
);
}
} catch (error) {
logWebhookError("Error deploying Compose:", error);
res.status(400).json({ message: "Error deploying Compose" });
res.status(400).json({ message: "Error deploying Compose", error });
return;
}
res.status(200).json({ message: "Compose deployed successfully" });
} catch (error) {
logWebhookError("Error deploying Compose:", error);
res.status(400).json({ message: "Error deploying Compose" });
console.log(error);
res.status(400).json({ message: "Error deploying Compose", error });
}
}

View File

@@ -17,22 +17,13 @@ import { applications, compose, github } 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 {
extractCommitMessage,
extractHash,
logWebhookError,
} from "./[refreshToken]";
import { extractCommitMessage, extractHash } from "./[refreshToken]";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const signature = req.headers["x-hub-signature-256"];
if (!signature) {
res.status(401).json({ message: "Missing signature header" });
return;
}
const githubBody = req.body;
if (!githubBody?.installation?.id) {
@@ -206,8 +197,10 @@ export default async function handler(
});
return;
} catch (error) {
logWebhookError("Error deploying applications on tag:", error);
res.status(400).json({ message: "Error deploying applications on tag" });
console.error("Error deploying applications on tag:", error);
res
.status(400)
.json({ message: "Error deploying applications on tag", error });
return;
}
}
@@ -329,8 +322,7 @@ export default async function handler(
}
res.status(200).json({ message: `Deployed ${totalApps} apps` });
} catch (error) {
logWebhookError("Error deploying Application:", error);
res.status(400).json({ message: "Error deploying Application" });
res.status(400).json({ message: "Error deploying Application", error });
}
} else if (req.headers["x-github-event"] === "pull_request") {
const prId = githubBody?.pull_request?.id;

View File

@@ -84,7 +84,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
if (!user) {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};
@@ -102,7 +102,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -24,7 +24,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -1,53 +0,0 @@
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 { ShowHome } from "@/components/dashboard/home/show-home";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
const Home = () => {
return <ShowHome />;
};
export default Home;
Home.getLayout = (page: ReactElement) => {
return <DashboardLayout>{page}</DashboardLayout>;
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user) {
return {
redirect: {
permanent: false,
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.settings.isCloud.prefetch();
await helpers.user.get.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
},
};
}

View File

@@ -95,8 +95,8 @@ export async function getServerSideProps(
if (IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
permanent: true,
destination: "/dashboard/projects",
},
};
}
@@ -104,7 +104,7 @@ export async function getServerSideProps(
if (!user) {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};
@@ -122,7 +122,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -32,7 +32,6 @@ import { AddAiAssistant } from "@/components/dashboard/project/add-ai-assistant"
import { AddApplication } from "@/components/dashboard/project/add-application";
import { AddCompose } from "@/components/dashboard/project/add-compose";
import { AddDatabase } from "@/components/dashboard/project/add-database";
import { AddImport } from "@/components/dashboard/project/add-import";
import { AddTemplate } from "@/components/dashboard/project/add-template";
import { AdvancedEnvironmentSelector } from "@/components/dashboard/project/advanced-environment-selector";
import { DuplicateProject } from "@/components/dashboard/project/duplicate-project";
@@ -510,14 +509,6 @@ const EnvironmentPage = (
deploy: api.mongo.deploy.useMutation(),
};
const libsqlActions = {
start: api.libsql.start.useMutation(),
stop: api.libsql.stop.useMutation(),
move: api.libsql.move.useMutation(),
delete: api.libsql.remove.useMutation(),
deploy: api.libsql.deploy.useMutation(),
};
const handleBulkStart = async () => {
let success = 0;
setIsBulkActionLoading(true);
@@ -550,9 +541,6 @@ const EnvironmentPage = (
case "mongo":
await mongoActions.start.mutateAsync({ mongoId: serviceId });
break;
case "libsql":
await libsqlActions.start.mutateAsync({ libsqlId: serviceId });
break;
}
success++;
} catch {
@@ -600,9 +588,6 @@ const EnvironmentPage = (
case "mongo":
await mongoActions.stop.mutateAsync({ mongoId: serviceId });
break;
case "libsql":
await libsqlActions.stop.mutateAsync({ libsqlId: serviceId });
break;
}
success++;
} catch {
@@ -679,12 +664,6 @@ const EnvironmentPage = (
targetEnvironmentId: selectedTargetEnvironment,
});
break;
case "libsql":
await libsqlActions.move.mutateAsync({
libsqlId: serviceId,
targetEnvironmentId: selectedTargetEnvironment,
});
break;
}
await utils.environment.one.invalidate({
environmentId,
@@ -754,11 +733,6 @@ const EnvironmentPage = (
mongoId: serviceId,
});
break;
case "libsql":
await libsqlActions.delete.mutateAsync({
libsqlId: serviceId,
});
break;
}
await utils.environment.one.invalidate({
environmentId,
@@ -825,11 +799,6 @@ const EnvironmentPage = (
mongoId: serviceId,
});
break;
case "libsql":
await libsqlActions.deploy.mutateAsync({
libsqlId: serviceId,
});
break;
}
success++;
} catch (error) {
@@ -1092,10 +1061,6 @@ const EnvironmentPage = (
projectName={projectData?.name}
environmentId={environmentId}
/>
<AddImport
projectName={projectData?.name}
environmentId={environmentId}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -1104,7 +1069,7 @@ const EnvironmentPage = (
</div>
<CardContent className="space-y-2 py-8 border-t gap-4 flex flex-col min-h-[60vh]">
<>
<div className="flex flex-col gap-4 2xl:flex-row 2xl:items-center 2xl:justify-between">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Checkbox
@@ -1625,9 +1590,9 @@ const EnvironmentPage = (
<ContextMenuTrigger asChild>
<Link
href={`/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}`}
className="block h-full"
className="block"
>
<Card className="flex flex-col h-full group relative cursor-pointer bg-transparent transition-colors hover:bg-border">
<Card className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border">
{service.serverId && (
<div className="absolute -left-1 -top-2">
<ServerIcon className="size-4 text-muted-foreground" />
@@ -1832,7 +1797,7 @@ export async function getServerSideProps(
if (!user) {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};
@@ -1891,7 +1856,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -18,6 +18,7 @@ import { ShowPorts } from "@/components/dashboard/application/advanced/ports/sho
import { ShowRedirects } from "@/components/dashboard/application/advanced/redirects/show-redirects";
import { ShowSecurity } from "@/components/dashboard/application/advanced/security/show-security";
import { ShowBuildServer } from "@/components/dashboard/application/advanced/show-build-server";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { ShowResources } from "@/components/dashboard/application/advanced/show-resources";
import { ShowTraefikConfig } from "@/components/dashboard/application/advanced/traefik/show-traefik-config";
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
@@ -93,7 +94,6 @@ const Service = (
);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: serverIp } = api.settings.getIp.useQuery();
const { data: auth } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
@@ -148,9 +148,8 @@ const Service = (
<Badge
className="cursor-pointer"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
copy(ip);
if (data?.server?.ipAddress) {
copy(data.server.ipAddress);
toast.success("IP Address Copied!");
}
}}
@@ -421,6 +420,11 @@ const Service = (
<ShowSecurity applicationId={applicationId} />
<ShowPorts applicationId={applicationId} />
<ShowTraefikConfig applicationId={applicationId} />
<TransferService
serviceId={applicationId}
serviceType="application"
currentServerId={data?.serverId ?? null}
/>
</div>
</TabsContent>
)}
@@ -453,7 +457,7 @@ export async function getServerSideProps(
if (!user) {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};
@@ -492,7 +496,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -22,6 +22,7 @@ import { ShowSchedules } from "@/components/dashboard/application/schedules/show
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { ShowComposeContainers } from "@/components/dashboard/compose/containers/show-compose-containers";
import { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
@@ -85,7 +86,6 @@ const Service = (
const { data: auth } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: serverIp } = api.settings.getIp.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
});
@@ -135,9 +135,8 @@ const Service = (
<Badge
className="cursor-pointer"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
copy(ip);
if (data?.server?.ipAddress) {
copy(data.server.ipAddress);
toast.success("IP Address Copied!");
}
}}
@@ -425,6 +424,11 @@ const Service = (
<ShowVolumes id={composeId} type="compose" />
<ShowImport composeId={composeId} />
<IsolatedDeploymentTab composeId={composeId} />
<TransferService
serviceId={composeId}
serviceType="compose"
currentServerId={data?.serverId ?? null}
/>
</div>
</TabsContent>
)}
@@ -457,7 +461,7 @@ export async function getServerSideProps(
if (!user) {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};
@@ -494,7 +498,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -1,4 +1,3 @@
import copy from "copy-to-clipboard";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import { HelpCircle, ServerOff } from "lucide-react";
@@ -11,7 +10,6 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import superjson from "superjson";
import { toast } from "sonner";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
import { DeleteService } from "@/components/dashboard/compose/delete-service";
@@ -63,7 +61,6 @@ const Libsql = (
const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: serverIp } = api.settings.getIp.useQuery();
return (
<div className="pb-10">
@@ -102,14 +99,6 @@ const Libsql = (
<div className="flex flex-col h-fit w-fit gap-2">
<div className="flex flex-row h-fit w-fit gap-2">
<Badge
className="cursor-pointer"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
copy(ip);
toast.success("IP Address Copied!");
}
}}
variant={
!data?.serverId
? "default"
@@ -318,7 +307,7 @@ export async function getServerSideProps(
if (!user) {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};
@@ -354,7 +343,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -1,4 +1,3 @@
import copy from "copy-to-clipboard";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import { HelpCircle, ServerOff } from "lucide-react";
@@ -11,7 +10,6 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import superjson from "superjson";
import { toast } from "sonner";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
import { DeleteService } from "@/components/dashboard/compose/delete-service";
@@ -65,7 +63,6 @@ const Mariadb = (
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: serverIp } = api.settings.getIp.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
@@ -114,14 +111,6 @@ const Mariadb = (
<div className="flex flex-col h-fit w-fit gap-2">
<div className="flex flex-row h-fit w-fit gap-2">
<Badge
className="cursor-pointer"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
copy(ip);
toast.success("IP Address Copied!");
}
}}
variant={
!data?.serverId
? "default"
@@ -314,6 +303,7 @@ const Mariadb = (
<ShowDatabaseAdvancedSettings
id={mariadbId}
type="mariadb"
serverId={data?.serverId}
/>
</div>
</TabsContent>
@@ -347,7 +337,7 @@ export async function getServerSideProps(
if (!user) {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};
@@ -383,7 +373,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -1,4 +1,3 @@
import copy from "copy-to-clipboard";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import { HelpCircle, ServerOff } from "lucide-react";
@@ -11,7 +10,6 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import superjson from "superjson";
import { toast } from "sonner";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
import { DeleteService } from "@/components/dashboard/compose/delete-service";
@@ -65,7 +63,6 @@ const Mongo = (
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: serverIp } = api.settings.getIp.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
});
@@ -113,14 +110,6 @@ const Mongo = (
<div className="flex flex-col h-fit w-fit gap-2">
<div className="flex flex-row h-fit w-fit gap-2">
<Badge
className="cursor-pointer"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
copy(ip);
toast.success("IP Address Copied!");
}
}}
variant={
!data?.serverId
? "default"
@@ -318,6 +307,7 @@ const Mongo = (
<ShowDatabaseAdvancedSettings
id={mongoId}
type="mongo"
serverId={data?.serverId}
/>
</div>
</TabsContent>
@@ -351,7 +341,7 @@ export async function getServerSideProps(
if (!user) {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};
@@ -387,7 +377,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -1,6 +1,5 @@
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import copy from "copy-to-clipboard";
import { HelpCircle, ServerOff } from "lucide-react";
import type {
GetServerSidePropsContext,
@@ -11,7 +10,6 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import superjson from "superjson";
import { toast } from "sonner";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
import { DeleteService } from "@/components/dashboard/compose/delete-service";
@@ -64,7 +62,6 @@ const MySql = (
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: serverIp } = api.settings.getIp.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
});
@@ -113,14 +110,6 @@ const MySql = (
<div className="flex flex-col h-fit w-fit gap-2">
<div className="flex flex-row h-fit w-fit gap-2">
<Badge
className="cursor-pointer"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
copy(ip);
toast.success("IP Address Copied!");
}
}}
variant={
!data?.serverId
? "default"
@@ -295,6 +284,7 @@ const MySql = (
<ShowDatabaseAdvancedSettings
id={mysqlId}
type="mysql"
serverId={data?.serverId}
/>
</div>
</TabsContent>
@@ -329,7 +319,7 @@ export async function getServerSideProps(
if (!user) {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};
@@ -364,7 +354,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -1,4 +1,3 @@
import copy from "copy-to-clipboard";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import { HelpCircle, ServerOff } from "lucide-react";
@@ -11,7 +10,6 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import superjson from "superjson";
import { toast } from "sonner";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
import { DeleteService } from "@/components/dashboard/compose/delete-service";
@@ -64,7 +62,6 @@ const Postgresql = (
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: serverIp } = api.settings.getIp.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
});
@@ -112,14 +109,6 @@ const Postgresql = (
<div className="flex flex-col h-fit w-fit gap-2">
<div className="flex flex-row h-fit w-fit gap-2">
<Badge
className="cursor-pointer"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
copy(ip);
toast.success("IP Address Copied!");
}
}}
variant={
!data?.serverId
? "default"
@@ -303,6 +292,7 @@ const Postgresql = (
<ShowDatabaseAdvancedSettings
id={postgresId}
type="postgres"
serverId={data?.serverId}
/>
</div>
</TabsContent>
@@ -335,7 +325,7 @@ export async function getServerSideProps(
if (!user) {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};
@@ -371,7 +361,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -1,4 +1,3 @@
import copy from "copy-to-clipboard";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import { HelpCircle, ServerOff } from "lucide-react";
@@ -11,7 +10,6 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import superjson from "superjson";
import { toast } from "sonner";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
import { DeleteService } from "@/components/dashboard/compose/delete-service";
@@ -64,7 +62,6 @@ const Redis = (
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: serverIp } = api.settings.getIp.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
});
@@ -112,14 +109,6 @@ const Redis = (
<div className="flex flex-col h-fit w-fit gap-2">
<div className="flex flex-row h-fit w-fit gap-2">
<Badge
className="cursor-pointer"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
copy(ip);
toast.success("IP Address Copied!");
}
}}
variant={
!data?.serverId
? "default"
@@ -307,6 +296,7 @@ const Redis = (
<ShowDatabaseAdvancedSettings
id={redisId}
type="redis"
serverId={data?.serverId}
/>
</div>
</TabsContent>
@@ -340,7 +330,7 @@ export async function getServerSideProps(
if (!user) {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};
@@ -374,7 +364,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -56,7 +56,7 @@ export async function getServerSideProps(
if (!user) {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};

View File

@@ -17,8 +17,8 @@ export async function getServerSideProps(
if (IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
permanent: true,
destination: "/dashboard/projects",
},
};
}
@@ -26,7 +26,7 @@ export async function getServerSideProps(
if (!user) {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};

View File

@@ -34,8 +34,8 @@ export async function getServerSideProps(
if (IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
permanent: true,
destination: "/dashboard/projects",
},
};
}
@@ -43,7 +43,7 @@ export async function getServerSideProps(
if (!user || (user.role !== "owner" && user.role !== "admin")) {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};

View File

@@ -45,7 +45,7 @@ export async function getServerSideProps(
if (!user || user.role === "member") {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};

View File

@@ -27,7 +27,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
if (!user) {
return {
redirect: { destination: "/", permanent: false },
redirect: { destination: "/", permanent: true },
};
}

View File

@@ -23,8 +23,8 @@ export async function getServerSideProps(
if (!IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
permanent: true,
destination: "/dashboard/projects",
},
};
}
@@ -33,7 +33,7 @@ export async function getServerSideProps(
if (!user || user.role !== "owner") {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};

View File

@@ -28,7 +28,7 @@ export async function getServerSideProps(
if (!user || user.role === "member") {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};

View File

@@ -27,8 +27,8 @@ export async function getServerSideProps(
if (IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
permanent: true,
destination: "/dashboard/projects",
},
};
}
@@ -36,7 +36,7 @@ export async function getServerSideProps(
if (!user || user.role === "member") {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};

View File

@@ -28,7 +28,7 @@ export async function getServerSideProps(
if (!user || user.role === "member") {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};

View File

@@ -27,7 +27,7 @@ export async function getServerSideProps(
if (!user) {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};
@@ -53,7 +53,7 @@ export async function getServerSideProps(
if (!userPermissions?.gitProviders.read) {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};

View File

@@ -23,8 +23,8 @@ export async function getServerSideProps(
if (!IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
permanent: true,
destination: "/dashboard/projects",
},
};
}
@@ -33,7 +33,7 @@ export async function getServerSideProps(
if (!user || user.role !== "owner") {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};

View File

@@ -38,7 +38,7 @@ export async function getServerSideProps(
if (!user) {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};
@@ -46,7 +46,7 @@ export async function getServerSideProps(
if (user.role !== "owner") {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/dashboard/settings/profile",
},
};

View File

@@ -1,39 +0,0 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import { ShowManagedServers } from "@/components/dashboard/settings/billing/show-managed-servers";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
const Page = () => {
return <ShowManagedServers />;
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Managed Servers">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext,
) {
if (!IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
},
};
}
const { user } = await validateRequest(ctx.req);
if (!user || user.role !== "owner") {
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
return { props: {} };
}

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