Compare commits

...

5 Commits

Author SHA1 Message Date
Mauricio Siu
0821398242 test: add buildsConcurrency setting to server configuration tests
- Introduced a new `buildsConcurrency` property in the server configuration tests to ensure proper handling of concurrent builds in deployment scenarios.
2026-06-16 07:32:52 -06:00
Mauricio Siu
25006f7fe7 refactor: enhance deployment cancellation logic and improve Railpack build isolation
- Reintroduced the `initCancelDeployments` function in the server initialization sequence to ensure deployments can be canceled effectively.
- Updated the Railpack build command to use a unique builder name for each build, preventing conflicts during concurrent deployments.
- Enhanced the cancellation logic to reset application and compose statuses to "idle" after canceling running deployments, improving system reliability.
2026-06-16 06:59:10 -06:00
Mauricio Siu
0429f40fce Merge branch 'canary' into feat/concurrent-deployments-in-memory-queue 2026-06-12 15:59:52 -06:00
Mauricio Siu
439f575669 refactor: unify server admin tools into dashboard pages with server selector (#4625)
* refactor: unify server admin tools into dashboard pages with server selector

Replace the per-server Advanced dropdown (Traefik file system, Docker
containers, swarm overview, swarm nodes, schedules) with a server
selector on the existing dashboard routes, defaulting to the Dokploy
server. Pages are now available in cloud too, since the dropdown was
the only entry point there; the cloud-only monitoring modal moves to
an icon button on the server card.

* feat: add frontend-design skill and enhance dashboard UI components

- Introduced a new skill for creating high-quality frontend designs, emphasizing intentional aesthetics and detailed guidelines for implementation.
- Updated the Traefik system component to improve the user experience when no files or directories are found, incorporating new icons and a more informative layout.
- Enhanced the server filter component with improved loading states, user prompts, and a more visually appealing design, including badges and better server information display.

* [autofix.ci] apply automated fixes

* style: adjust Card component layout in schedules page for improved responsiveness

- Modified the Card component in the schedules page to ensure it utilizes full width while maintaining the minimum height, enhancing the overall layout and user experience.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-12 14:39:08 -06:00
Mauricio Siu
fa25fef57b feat: add builds concurrency management for servers
- Introduced a new `BuildsConcurrency` component to manage the number of concurrent builds for both local and remote servers, gated by license validity.
- Implemented backend logic to resolve effective builds concurrency based on server settings and organization licenses.
- Added unit tests for concurrency resolution logic to ensure correct behavior under various licensing scenarios.
- Updated database schema to include `buildsConcurrency` field for servers and web server settings.
- Refactored deployment queue to support in-memory job processing with configurable concurrency limits.

This feature enhances deployment flexibility and control for enterprise users.
2026-06-12 13:45:22 -06:00
39 changed files with 9943 additions and 487 deletions

View File

@@ -0,0 +1,42 @@
---
name: frontend-design
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
license: Complete terms in LICENSE.txt
---
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Technical requirements (framework, performance, accessibility).
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
## Frontend Aesthetics Guidelines
Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.

View File

@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const hasValidLicense = vi.fn();
const getWebServerSettings = vi.fn();
const findFirstOrg = vi.fn();
const findFirstServer = vi.fn();
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
organization: {
findFirst: (...args: unknown[]) => findFirstOrg(...args),
},
server: {
findFirst: (...args: unknown[]) => findFirstServer(...args),
},
},
},
}));
vi.mock("@dokploy/server/db/schema", () => ({
organization: {},
server: {},
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: (...args: unknown[]) => hasValidLicense(...args),
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: (...args: unknown[]) => getWebServerSettings(...args),
}));
vi.mock("drizzle-orm", () => ({ eq: vi.fn() }));
import { resolveBuildsConcurrency } from "../../server/queues/concurrency";
import { LOCAL_PARTITION } from "../../server/queues/in-memory-queue";
describe("resolveBuildsConcurrency (enterprise gating)", () => {
beforeEach(() => {
vi.clearAllMocks();
findFirstOrg.mockResolvedValue({ id: "org-1" });
});
describe("local web server partition", () => {
it("returns the configured concurrency when licensed", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 5 });
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(5);
});
it("clamps to 1 when there is no valid license", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 10 });
hasValidLicense.mockResolvedValue(false);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
});
it("caps the configured value at 20 when licensed", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 999 });
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(20);
});
it("defaults to 1 when settings are missing", async () => {
getWebServerSettings.mockResolvedValue(undefined);
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
});
});
describe("remote server partition", () => {
it("returns the server concurrency when its org is licensed", async () => {
findFirstServer.mockResolvedValue({
buildsConcurrency: 4,
organizationId: "org-1",
});
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency("server-1")).resolves.toBe(4);
expect(hasValidLicense).toHaveBeenCalledWith("org-1");
});
it("clamps to 1 when the server org is not licensed", async () => {
findFirstServer.mockResolvedValue({
buildsConcurrency: 8,
organizationId: "org-1",
});
hasValidLicense.mockResolvedValue(false);
await expect(resolveBuildsConcurrency("server-1")).resolves.toBe(1);
});
it("defaults to 1 for an unknown server", async () => {
findFirstServer.mockResolvedValue(undefined);
await expect(resolveBuildsConcurrency("ghost")).resolves.toBe(1);
});
});
it("falls back to 1 if resolution throws", async () => {
getWebServerSettings.mockRejectedValue(new Error("db down"));
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
});
});

View File

@@ -0,0 +1,337 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
getGroup,
getPartition,
InMemoryQueue,
LOCAL_PARTITION,
} from "../../server/queues/in-memory-queue";
import type { DeploymentJob } from "../../server/queues/queue-types";
const appJob = (applicationId: string, serverId?: string): DeploymentJob => ({
applicationId,
titleLog: "deploy",
descriptionLog: "",
type: "deploy",
applicationType: "application",
serverId,
});
const composeJob = (composeId: string, serverId?: string): DeploymentJob => ({
composeId,
titleLog: "deploy",
descriptionLog: "",
type: "deploy",
applicationType: "compose",
serverId,
});
/** A controllable async task: resolves only when `release()` is called. */
const deferred = () => {
let resolve!: () => void;
const promise = new Promise<void>((r) => {
resolve = r;
});
return { promise, release: resolve };
};
const flush = () => new Promise((r) => setTimeout(r, 0));
describe("getPartition / getGroup", () => {
it("partitions by serverId, falling back to the local partition", () => {
expect(getPartition(appJob("a"))).toBe(LOCAL_PARTITION);
expect(getPartition(appJob("a", "server-1"))).toBe("server-1");
});
it("groups applications and compose by their id", () => {
expect(getGroup(appJob("a"))).toBe("application:a");
expect(getGroup(composeJob("c"))).toBe("compose:c");
});
});
describe("InMemoryQueue concurrency", () => {
let nowValue = 0;
const now = () => ++nowValue;
beforeEach(() => {
nowValue = 0;
});
it("runs different applications concurrently up to the limit", async () => {
const tasks = new Map<string, ReturnType<typeof deferred>>();
const started: string[] = [];
const queue = new InMemoryQueue({ resolveConcurrency: () => 2, now });
queue.process(async (job) => {
const id = (job.data as any).applicationId;
started.push(id);
const d = deferred();
tasks.set(id, d);
await d.promise;
});
await queue.run();
await queue.add(appJob("a"));
await queue.add(appJob("b"));
await queue.add(appJob("c"));
await flush();
// Concurrency 2 -> only a and b start, c waits.
expect(started).toEqual(["a", "b"]);
tasks.get("a")!.release();
await flush();
// A slot freed -> c starts.
expect(started).toEqual(["a", "b", "c"]);
});
it("serializes jobs of the same application (per-group FIFO)", async () => {
const tasks: Array<ReturnType<typeof deferred>> = [];
const started: number[] = [];
let counter = 0;
const queue = new InMemoryQueue({ resolveConcurrency: () => 5, now });
queue.process(async () => {
started.push(++counter);
const d = deferred();
tasks.push(d);
await d.promise;
});
await queue.run();
// Two deploys of the SAME app, even with concurrency 5.
await queue.add(appJob("same"));
await queue.add(appJob("same"));
await flush();
// Only the first one runs; the second waits for the group to free.
expect(started).toEqual([1]);
tasks[0]!.release();
await flush();
expect(started).toEqual([1, 2]);
});
it("isolates concurrency per server partition", async () => {
const started: string[] = [];
const tasks = new Map<string, ReturnType<typeof deferred>>();
// server-1 allows 1, server-2 allows 1, but they are independent.
const queue = new InMemoryQueue({
resolveConcurrency: () => 1,
now,
});
queue.process(async (job) => {
const id = `${job.data.serverId}:${(job.data as any).applicationId}`;
started.push(id);
const d = deferred();
tasks.set(id, d);
await d.promise;
});
await queue.run();
await queue.add(appJob("a", "server-1"));
await queue.add(appJob("b", "server-2"));
await flush();
// One per partition runs in parallel despite concurrency 1 each.
expect(started.sort()).toEqual(["server-1:a", "server-2:b"]);
});
it("honors a different concurrency per server", async () => {
const started: string[] = [];
const tasks = new Map<string, ReturnType<typeof deferred>>();
// server-fast allows 2, server-slow allows 1.
const queue = new InMemoryQueue({
resolveConcurrency: (partition) => (partition === "server-fast" ? 2 : 1),
now,
});
queue.process(async (job) => {
const id = `${job.data.serverId}:${(job.data as any).applicationId}`;
started.push(id);
const d = deferred();
tasks.set(id, d);
await d.promise;
});
await queue.run();
await queue.add(appJob("a", "server-fast"));
await queue.add(appJob("b", "server-fast"));
await queue.add(appJob("c", "server-slow"));
await queue.add(appJob("d", "server-slow"));
await flush();
// server-fast runs 2 in parallel; server-slow only 1.
expect(started.sort()).toEqual([
"server-fast:a",
"server-fast:b",
"server-slow:c",
]);
// Free a server-slow slot -> its queued app starts.
tasks.get("server-slow:c")!.release();
await flush();
expect(started).toContain("server-slow:d");
});
it("serializes the same app on a server even with spare concurrency", async () => {
const started: number[] = [];
const tasks: Array<ReturnType<typeof deferred>> = [];
let counter = 0;
// Plenty of room (concurrency 2) but two deploys of the SAME app.
const queue = new InMemoryQueue({ resolveConcurrency: () => 2, now });
queue.process(async () => {
started.push(++counter);
const d = deferred();
tasks.push(d);
await d.promise;
});
await queue.run();
await queue.add(appJob("app-x", "server-1"));
await queue.add(appJob("app-x", "server-1"));
await flush();
// Only one build of app-x runs despite 2 free slots.
expect(started).toEqual([1]);
tasks[0]!.release();
await flush();
expect(started).toEqual([1, 2]);
});
it("clamps concurrency below 1 up to 1 (license-disabled behaviour)", async () => {
const started: string[] = [];
const tasks = new Map<string, ReturnType<typeof deferred>>();
// Simulate a non-licensed resolver returning 0 — must still run 1.
const queue = new InMemoryQueue({ resolveConcurrency: () => 0, now });
queue.process(async (job) => {
const id = (job.data as any).applicationId;
started.push(id);
const d = deferred();
tasks.set(id, d);
await d.promise;
});
await queue.run();
await queue.add(appJob("a"));
await queue.add(appJob("b"));
await flush();
expect(started).toEqual(["a"]);
});
it("picks up concurrency changes between scheduling ticks", async () => {
const started: string[] = [];
const tasks = new Map<string, ReturnType<typeof deferred>>();
let limit = 1;
const queue = new InMemoryQueue({
resolveConcurrency: () => limit,
now,
});
queue.process(async (job) => {
const id = (job.data as any).applicationId;
started.push(id);
const d = deferred();
tasks.set(id, d);
await d.promise;
});
await queue.run();
await queue.add(appJob("a"));
await queue.add(appJob("b"));
await flush();
expect(started).toEqual(["a"]);
// Raise the limit (e.g. license activated) and release the running job
// so a new tick observes the new concurrency.
limit = 2;
tasks.get("a")!.release();
await flush();
expect(started).toContain("b");
});
});
describe("InMemoryQueue job management", () => {
it("lists waiting jobs and removes them by predicate", async () => {
const block = deferred();
const queue = new InMemoryQueue({ resolveConcurrency: () => 1 });
queue.process(async () => {
await block.promise;
});
await queue.run();
await queue.add(appJob("running"));
await queue.add(appJob("waiting-1"));
await queue.add(composeJob("waiting-2"));
await flush();
const waiting = await queue.getJobs(["waiting"]);
expect(waiting.map((j) => j.data)).toHaveLength(2);
const removed = queue.removeWaiting(
(data) => (data as any).applicationId === "waiting-1",
);
expect(removed).toBe(1);
const after = await queue.getJobs(["waiting"]);
expect(after).toHaveLength(1);
});
it("clears all waiting jobs", async () => {
const block = deferred();
const queue = new InMemoryQueue({ resolveConcurrency: () => 1 });
queue.process(async () => {
await block.promise;
});
await queue.run();
await queue.add(appJob("running"));
await queue.add(appJob("waiting-1"));
await queue.add(appJob("waiting-2"));
await flush();
expect(queue.clearWaiting()).toBe(2);
expect(await queue.getJobs(["waiting"])).toHaveLength(0);
});
it("starts processing as soon as a processor is registered", async () => {
const started: string[] = [];
const queue = new InMemoryQueue({ resolveConcurrency: () => 5 });
// No processor yet -> jobs queue but do not run.
await queue.add(appJob("a"));
await flush();
expect(started).toEqual([]);
// Registering the processor auto-starts the queue (no separate run()).
queue.process(async (job) => {
started.push((job.data as any).applicationId);
});
await flush();
expect(started).toEqual(["a"]);
});
it("continues scheduling after a job throws", async () => {
const started: string[] = [];
const queue = new InMemoryQueue({ resolveConcurrency: () => 1 });
queue.process(async (job) => {
const id = (job.data as any).applicationId;
started.push(id);
if (id === "a") throw new Error("boom");
});
await queue.run();
await queue.add(appJob("a"));
await queue.add(appJob("b"));
await flush();
expect(started).toEqual(["a", "b"]);
});
});

View File

@@ -25,6 +25,7 @@ const baseSettings: WebServerSettings = {
letsEncryptEmail: null,
sshPrivateKey: null,
enableDockerCleanup: false,
buildsConcurrency: 1,
logCleanupCron: null,
metricsConfig: {
containers: {

View File

@@ -1,4 +1,11 @@
import { FileIcon, Folder, Loader2, Workflow } from "lucide-react";
import {
FileIcon,
Folder,
FolderOpen,
Loader2,
MousePointerClick,
Workflow,
} from "lucide-react";
import React from "react";
import { AlertBlock } from "@/components/shared/alert-block";
import {
@@ -68,12 +75,22 @@ export const ShowTraefikSystem = ({ serverId }: Props) => {
</div>
)}
{directories?.length === 0 && (
<div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]">
<span className="text-muted-foreground text-lg font-medium">
No directories or files detected in{" "}
{"'/etc/dokploy/traefik'"}
</span>
<Folder className="size-8 text-muted-foreground" />
<div className="w-full flex-col gap-4 flex items-center justify-center h-[55vh] border border-dashed rounded-lg">
<div className="flex items-center justify-center size-14 rounded-full bg-muted">
<FolderOpen className="size-7 text-muted-foreground" />
</div>
<div className="flex flex-col items-center gap-1 text-center px-4">
<span className="text-base font-medium">
No configuration files found
</span>
<span className="text-sm text-muted-foreground">
There are no directories or files in{" "}
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">
/etc/dokploy/traefik
</code>{" "}
on this server yet.
</span>
</div>
</div>
)}
{directories && directories?.length > 0 && (
@@ -89,11 +106,19 @@ export const ShowTraefikSystem = ({ serverId }: Props) => {
{file ? (
<ShowTraefikFile path={file} serverId={serverId} />
) : (
<div className="h-full w-full flex-col gap-2 flex items-center justify-center">
<span className="text-muted-foreground text-lg font-medium">
No file selected
</span>
<FileIcon className="size-8 text-muted-foreground" />
<div className="h-full min-h-[300px] w-full flex-col gap-4 flex items-center justify-center border border-dashed rounded-lg">
<div className="flex items-center justify-center size-14 rounded-full bg-muted">
<MousePointerClick className="size-7 text-muted-foreground" />
</div>
<div className="flex flex-col items-center gap-1 text-center px-4">
<span className="text-base font-medium">
Select a file to edit
</span>
<span className="text-sm text-muted-foreground">
Choose a file from the tree on the left to view
and edit its contents.
</span>
</div>
</div>
)}
</div>

View File

@@ -1,30 +0,0 @@
import { useState } from "react";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { ShowNodes } from "./show-nodes";
interface Props {
serverId: string;
}
export const ShowNodesModal = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Swarm Nodes
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="min-w-[70vw]">
<div className="grid w-full gap-1">
<ShowNodes serverId={serverId} />
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,106 @@
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const clamp = (value: number) => Math.min(20, Math.max(1, value));
interface Props {
/**
* When provided, configures concurrency for that remote server. When
* omitted, configures the local Dokploy web server.
*/
serverId?: string;
}
/**
* Enterprise-only control to set the number of concurrent builds, either for a
* remote server (`serverId` provided) or the local web server (omitted).
* Hidden when the instance has no valid license.
*/
export const BuildsConcurrency = ({ serverId }: Props) => {
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: haveValidLicense } =
api.licenseKey.haveValidLicenseKey.useQuery();
const serverQuery = api.server.one.useQuery(
{ serverId: serverId ?? "" },
{ enabled: !!serverId },
);
const webServerQuery = api.settings.getWebServerSettings.useQuery(undefined, {
enabled: !serverId,
});
const current = serverId
? serverQuery.data?.buildsConcurrency
: webServerQuery.data?.buildsConcurrency;
const refetch = serverId ? serverQuery.refetch : webServerQuery.refetch;
const updateServer = api.server.updateBuildsConcurrency.useMutation();
const updateWebServer = api.settings.updateBuildsConcurrency.useMutation();
const isPending = serverId
? updateServer.isPending
: updateWebServer.isPending;
const [value, setValue] = useState("1");
useEffect(() => {
if (current) {
setValue(String(current));
}
}, [current]);
// Concurrent builds are a self-hosted enterprise feature; not shown in cloud.
if (isCloud || !haveValidLicense) return null;
const handleSave = async () => {
const parsed = clamp(Number.parseInt(value, 10) || 1);
setValue(String(parsed));
try {
if (serverId) {
await updateServer.mutateAsync({ serverId, buildsConcurrency: parsed });
} else {
await updateWebServer.mutateAsync({ buildsConcurrency: parsed });
}
await refetch();
toast.success("Builds concurrency updated");
} catch {
toast.error("Error updating builds concurrency");
}
};
const hasChanges = Number(value) !== (current ?? 1);
return (
<div className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<p className="text-sm font-medium">Concurrent Builds</p>
<p className="text-sm text-muted-foreground">
Maximum number of deployments that can build at the same time on
{serverId ? " this server" : " the local Dokploy server"}. Builds of
the same service are always serialized.
</p>
</div>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={20}
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-20"
/>
<Button
type="button"
size="sm"
onClick={handleSave}
isLoading={isPending}
disabled={!hasChanges}
>
Save
</Button>
</div>
</div>
);
};

View File

@@ -39,6 +39,7 @@ import {
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { BuildsConcurrency } from "./actions/builds-concurrency";
const Schema = z.object({
name: z.string().min(1, {
@@ -444,6 +445,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
</FormItem>
)}
/>
{serverId && <BuildsConcurrency serverId={serverId} />}
</form>
<DialogFooter>

View File

@@ -1,30 +0,0 @@
import { useState } from "react";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { ShowContainers } from "../../docker/show/show-containers";
interface Props {
serverId: string;
}
export const ShowDockerContainersModal = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Docker Containers
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-7xl ">
<div className="grid w-full gap-1">
<ShowContainers serverId={serverId} />
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,6 +1,7 @@
import { BarChartHorizontalBigIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { ShowPaidMonitoring } from "../../monitoring/paid/servers/show-paid-monitoring";
interface Props {
@@ -14,12 +15,9 @@ export const ShowMonitoringModal = ({ url, token }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Monitoring
</DropdownMenuItem>
<Button variant="outline" size="icon" className="h-9 w-9">
<BarChartHorizontalBigIcon className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-7xl ">
<div className="flex gap-4 py-4 w-full">

View File

@@ -1,28 +0,0 @@
import { useState } from "react";
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
interface Props {
serverId: string;
}
export const ShowSchedulesModal = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Schedules
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-5xl ">
<ShowSchedules id={serverId} scheduleType="server" />
</DialogContent>
</Dialog>
);
};

View File

@@ -4,7 +4,6 @@ import {
Key,
KeyIcon,
Loader2,
MoreHorizontal,
Network,
ServerIcon,
Terminal,
@@ -25,12 +24,6 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
@@ -38,16 +31,11 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal";
import { TerminalModal } from "../web-server/terminal-modal";
import { ShowServerActions } from "./actions/show-server-actions";
import { HandleServers } from "./handle-servers";
import { SetupServer } from "./setup-server";
import { ShowDockerContainersModal } from "./show-docker-containers-modal";
import { ShowMonitoringModal } from "./show-monitoring-modal";
import { ShowSchedulesModal } from "./show-schedules-modal";
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { WelcomeSubscription } from "./welcome-stripe/welcome-subscription";
export const ShowServers = () => {
@@ -138,52 +126,6 @@ export const ShowServers = () => {
{server.name}
</CardTitle>
</div>
{isActive &&
server.sshKeyId &&
!isBuildServer && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 shrink-0 p-0"
>
<span className="sr-only">
More options
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
Advanced
</DropdownMenuLabel>
<ShowTraefikFileSystemModal
serverId={server.serverId}
/>
<ShowDockerContainersModal
serverId={server.serverId}
/>
{isCloud && (
<ShowMonitoringModal
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
token={
server?.metricsConfig?.server
?.token
}
/>
)}
<ShowSwarmOverviewModal
serverId={server.serverId}
/>
<ShowNodesModal
serverId={server.serverId}
/>
<ShowSchedulesModal
serverId={server.serverId}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<TooltipProvider>
<div className="flex gap-2 mt-2 flex-wrap">
@@ -361,6 +303,27 @@ export const ShowServers = () => {
</Tooltip>
)}
{isCloud &&
server.sshKeyId &&
!isBuildServer && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<ShowMonitoringModal
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
token={
server?.metricsConfig
?.server?.token
}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Monitoring</p>
</TooltipContent>
</Tooltip>
)}
<div className="flex-1" />
{permissions?.server.delete && (

View File

@@ -1,48 +0,0 @@
import { useState } from "react";
import { Card } from "@/components/ui/card";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ShowSwarmContainers } from "../../swarm/containers/show-swarm-containers";
import SwarmMonitorCard from "../../swarm/monitoring-card";
interface Props {
serverId: string;
}
export const ShowSwarmOverviewModal = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Swarm Overview
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-7xl ">
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="containers">Containers</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<div className="grid w-full gap-1">
<SwarmMonitorCard serverId={serverId} />
</div>
</TabsContent>
<TabsContent value="containers">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md p-6">
<ShowSwarmContainers serverId={serverId} />
</div>
</Card>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,28 +0,0 @@
import { useState } from "react";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { ShowTraefikSystem } from "../../file-system/show-traefik-system";
interface Props {
serverId: string;
}
export const ShowTraefikFileSystemModal = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Traefik File System
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-7xl ">
<ShowTraefikSystem serverId={serverId} />
</DialogContent>
</Dialog>
);
};

View File

@@ -182,36 +182,31 @@ const MENU: Menu = {
title: "Schedules",
url: "/dashboard/schedules",
icon: Clock,
// Only enabled in non-cloud environments
isEnabled: ({ isCloud, permissions }) =>
!isCloud && !!permissions?.organization.update,
isEnabled: ({ permissions }) => !!permissions?.organization.update,
},
{
isSingle: true,
title: "Traefik File System",
url: "/dashboard/traefik",
icon: GalleryVerticalEnd,
// Only enabled for users with access to Traefik files in non-cloud environments
isEnabled: ({ permissions, isCloud }) =>
!!(permissions?.traefikFiles.read && !isCloud),
// Only enabled for users with access to Traefik files
isEnabled: ({ permissions }) => !!permissions?.traefikFiles.read,
},
{
isSingle: true,
title: "Docker",
url: "/dashboard/docker",
icon: BlocksIcon,
// Only enabled for users with access to Docker in non-cloud environments
isEnabled: ({ permissions, isCloud }) =>
!!(permissions?.docker.read && !isCloud),
// Only enabled for users with access to Docker
isEnabled: ({ permissions }) => !!permissions?.docker.read,
},
{
isSingle: true,
title: "Swarm",
url: "/dashboard/swarm",
icon: PieChart,
// Only enabled for users with access to Docker in non-cloud environments
isEnabled: ({ permissions, isCloud }) =>
!!(permissions?.docker.read && !isCloud),
// Only enabled for users with access to Docker
isEnabled: ({ permissions }) => !!permissions?.docker.read,
},
{
isSingle: true,
@@ -375,9 +370,8 @@ const MENU: Menu = {
title: "Cluster",
url: "/dashboard/settings/cluster",
icon: Boxes,
// Only enabled for admins in non-cloud environments
isEnabled: ({ permissions, isCloud }) =>
!!(permissions?.organization.update && !isCloud),
// Only enabled for admins
isEnabled: ({ permissions }) => !!permissions?.organization.update,
},
{
isSingle: true,

View File

@@ -0,0 +1,156 @@
import { Loader2, PlusIcon, ServerIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { Fragment, type ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
const DOKPLOY_SERVER = "dokploy-server";
interface Props {
children: (serverId?: string) => ReactNode;
}
export const ServerFilter = ({ children }: Props) => {
const router = useRouter();
const { data: servers, isLoading: isLoadingServers } =
api.server.withSSHKey.useQuery();
const { data: isCloud, isLoading: isLoadingCloud } =
api.settings.isCloud.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const queryServerId =
typeof router.query.serverId === "string"
? router.query.serverId
: undefined;
const selectedServer = servers?.find(
(server) => server.serverId === queryServerId,
);
// Cloud has no local Dokploy server, so fall back to the first remote server
const serverId = selectedServer
? selectedServer.serverId
: isCloud
? servers?.[0]?.serverId
: undefined;
const setServerId = (value: string) => {
const { serverId: _current, ...query } = router.query;
router.replace(
{
pathname: router.pathname,
query: value === DOKPLOY_SERVER ? query : { ...query, serverId: value },
},
undefined,
{ shallow: true },
);
};
if (isLoadingServers || isLoadingCloud) {
return (
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md flex flex-col gap-2 items-center justify-center min-h-[60vh]">
<span className="text-muted-foreground text-lg font-medium">
Loading...
</span>
<Loader2 className="animate-spin size-8 text-muted-foreground" />
</div>
</Card>
);
}
if (isCloud && !servers?.length) {
return (
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md flex flex-col items-center justify-center gap-5 min-h-[60vh] border border-dashed px-4">
<div className="flex items-center justify-center size-16 rounded-full bg-muted">
<ServerIcon className="size-8 text-muted-foreground" />
</div>
<div className="flex flex-col items-center gap-1.5 text-center max-w-md">
<span className="text-lg font-medium">No servers yet</span>
<span className="text-sm text-muted-foreground">
{permissions?.server.create
? "This section works on your remote servers. Add your first server to start managing it from here."
: "This section works on your remote servers. Ask an administrator to add a server to your organization."}
</span>
</div>
{permissions?.server.create && (
<Button asChild>
<Link href="/dashboard/settings/servers">
<PlusIcon className="size-4" />
Add Server
</Link>
</Button>
)}
</div>
</Card>
);
}
return (
<div className="flex flex-col gap-4 w-full">
{!!servers?.length && (
<div className="flex w-full items-center justify-end gap-3">
<Label
htmlFor="server-filter"
className="text-sm text-muted-foreground whitespace-nowrap"
>
Viewing server
</Label>
<Select
value={serverId ?? DOKPLOY_SERVER}
onValueChange={setServerId}
>
<SelectTrigger id="server-filter" className="w-fit min-w-[220px]">
<div className="flex items-center gap-2">
<ServerIcon className="size-4 text-muted-foreground" />
<SelectValue placeholder="Select a server" />
</div>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Servers</SelectLabel>
{!isCloud && (
<SelectItem value={DOKPLOY_SERVER}>
<div className="flex items-center gap-2">
<span>Dokploy Server</span>
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0"
>
Local
</Badge>
</div>
</SelectItem>
)}
{servers.map((server) => (
<SelectItem key={server.serverId} value={server.serverId}>
<div className="flex items-center gap-2">
<span>{server.name}</span>
<span className="text-xs text-muted-foreground">
{server.ipAddress}
</span>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
)}
<Fragment key={serverId ?? DOKPLOY_SERVER}>{children(serverId)}</Fragment>
</div>
);
};

View File

@@ -0,0 +1,2 @@
ALTER TABLE "server" ADD COLUMN "buildsConcurrency" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
ALTER TABLE "webServerSettings" ADD COLUMN "buildsConcurrency" integer DEFAULT 1 NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -1205,6 +1205,13 @@
"when": 1780775037209,
"tag": "0171_lucky_echo",
"breakpoints": true
},
{
"idx": 172,
"version": "7",
"when": 1781045439162,
"tag": "0172_quick_the_professor",
"breakpoints": true
}
]
}

View File

@@ -1,4 +1,3 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
@@ -6,10 +5,15 @@ import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowContainers } from "@/components/dashboard/docker/show/show-containers";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { ServerFilter } from "@/components/shared/server-filter";
import { appRouter } from "@/server/api/root";
const Dashboard = () => {
return <ShowContainers />;
return (
<ServerFilter>
{(serverId) => <ShowContainers serverId={serverId} />}
</ServerFilter>
);
};
export default Dashboard;
@@ -20,14 +24,6 @@ Dashboard.getLayout = (page: ReactElement) => {
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
if (IS_CLOUD) {
return {
redirect: {
permanent: true,
destination: "/dashboard/home",
},
};
}
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {

View File

@@ -1,20 +1,27 @@
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 { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { ServerFilter } from "@/components/shared/server-filter";
import { Card } from "@/components/ui/card";
function SchedulesPage() {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-8xl mx-auto min-h-[45vh]">
<div className="rounded-xl bg-background shadow-md h-full">
<ShowSchedules scheduleType="dokploy-server" id="dokploy-server" />
<ServerFilter>
{(serverId) => (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full min-h-[45vh]">
<div className="rounded-xl bg-background shadow-md h-full">
<ShowSchedules
scheduleType={serverId ? "server" : "dokploy-server"}
id={serverId ?? "dokploy-server"}
/>
</div>
</Card>
</div>
</Card>
</div>
)}
</ServerFilter>
);
}
export default SchedulesPage;
@@ -26,14 +33,6 @@ SchedulesPage.getLayout = (page: ReactElement) => {
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
if (IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
},
};
}
const { user } = await validateRequest(ctx.req);
if (!user || (user.role !== "owner" && user.role !== "admin")) {
return {

View File

@@ -1,17 +1,22 @@
import { IS_CLOUD, validateRequest } from "@dokploy/server";
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowNodes } from "@/components/dashboard/settings/cluster/nodes/show-nodes";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { ServerFilter } from "@/components/shared/server-filter";
import { appRouter } from "@/server/api/root";
const Page = () => {
return (
<div className="flex flex-col gap-4 w-full">
<ShowNodes />
</div>
<ServerFilter>
{(serverId) => (
<div className="flex flex-col gap-4 w-full">
<ShowNodes serverId={serverId} />
</div>
)}
</ServerFilter>
);
};
@@ -24,14 +29,6 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
if (IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
},
};
}
const { user, session } = await validateRequest(ctx.req);
if (!user || user.role === "member") {
return {

View File

@@ -3,6 +3,7 @@ import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { BuildsConcurrency } from "@/components/dashboard/settings/servers/actions/builds-concurrency";
import { ToggleEnforceSSO } from "@/components/dashboard/settings/servers/actions/toggle-enforce-sso";
import { ToggleRemoteServersOnly } from "@/components/dashboard/settings/servers/actions/toggle-remote-servers-only";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
@@ -78,6 +79,7 @@ const Page = ({ isCloud }: Props) => {
<CardContent className="flex flex-col gap-4">
<ToggleRemoteServersOnly />
<ToggleEnforceSSO />
<BuildsConcurrency />
</CardContent>
</EnterpriseFeatureGate>
</div>

View File

@@ -1,4 +1,3 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
@@ -7,30 +6,35 @@ import superjson from "superjson";
import { ShowSwarmContainers } from "@/components/dashboard/swarm/containers/show-swarm-containers";
import SwarmMonitorCard from "@/components/dashboard/swarm/monitoring-card";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { ServerFilter } from "@/components/shared/server-filter";
import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { appRouter } from "@/server/api/root";
const Dashboard = () => {
return (
<div className="space-y-4">
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="containers">Containers</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<SwarmMonitorCard />
</TabsContent>
<TabsContent value="containers">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md p-6">
<ShowSwarmContainers />
</div>
</Card>
</TabsContent>
</Tabs>
</div>
<ServerFilter>
{(serverId) => (
<div className="space-y-4">
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="containers">Containers</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<SwarmMonitorCard serverId={serverId} />
</TabsContent>
<TabsContent value="containers">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md p-6">
<ShowSwarmContainers serverId={serverId} />
</div>
</Card>
</TabsContent>
</Tabs>
</div>
)}
</ServerFilter>
);
};
@@ -42,14 +46,6 @@ Dashboard.getLayout = (page: ReactElement) => {
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
if (IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
},
};
}
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {

View File

@@ -1,4 +1,3 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
@@ -6,10 +5,15 @@ import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowTraefikSystem } from "@/components/dashboard/file-system/show-traefik-system";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { ServerFilter } from "@/components/shared/server-filter";
import { appRouter } from "@/server/api/root";
const Dashboard = () => {
return <ShowTraefikSystem />;
return (
<ServerFilter>
{(serverId) => <ShowTraefikSystem serverId={serverId} />}
</ServerFilter>
);
};
export default Dashboard;
@@ -20,14 +24,6 @@ Dashboard.getLayout = (page: ReactElement) => {
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
if (IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
},
};
}
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {

View File

@@ -68,11 +68,9 @@ import {
environments,
projects,
} from "@/server/db/schema";
import { deploymentWorker } from "@/server/queues/deployments-queue";
import type { DeploymentJob } from "@/server/queues/queue-types";
import {
cleanQueuesByApplication,
getJobsByApplicationId,
killDockerBuild,
myQueue,
} from "@/server/queues/queueSetup";
@@ -242,12 +240,7 @@ export const applicationRouter = createTRPCRouter({
.returning();
if (!IS_CLOUD) {
const queueJobs = await getJobsByApplicationId(input.applicationId);
for (const job of queueJobs) {
if (job.id) {
deploymentWorker.cancelJob(job.id, "User requested cancellation");
}
}
await cleanQueuesByApplication(input.applicationId);
}
const cleanupOperations = [
@@ -339,10 +332,10 @@ export const applicationRouter = createTRPCRouter({
type: "redeploy",
applicationType: "application",
server: !!application.serverId,
serverId: application.serverId ?? undefined,
};
if (IS_CLOUD && application.serverId) {
jobData.serverId = application.serverId;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
@@ -707,9 +700,9 @@ export const applicationRouter = createTRPCRouter({
type: "deploy",
applicationType: "application",
server: !!application.serverId,
serverId: application.serverId ?? undefined,
};
if (IS_CLOUD && application.serverId) {
jobData.serverId = application.serverId;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
@@ -826,9 +819,9 @@ export const applicationRouter = createTRPCRouter({
type: "deploy",
applicationType: "application",
server: !!app.serverId,
serverId: app.serverId ?? undefined,
};
if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});

View File

@@ -68,11 +68,9 @@ import {
environments,
projects,
} from "@/server/db/schema";
import { deploymentWorker } from "@/server/queues/deployments-queue";
import type { DeploymentJob } from "@/server/queues/queue-types";
import {
cleanQueuesByCompose,
getJobsByComposeId,
killDockerBuild,
myQueue,
} from "@/server/queues/queueSetup";
@@ -252,12 +250,7 @@ export const composeRouter = createTRPCRouter({
.returning();
if (!IS_CLOUD) {
const queueJobs = await getJobsByComposeId(input.composeId);
for (const job of queueJobs) {
if (job.id) {
deploymentWorker.cancelJob(job.id, "User requested cancellation");
}
}
await cleanQueuesByCompose(input.composeId);
}
const cleanupOperations = [
@@ -430,10 +423,10 @@ export const composeRouter = createTRPCRouter({
applicationType: "compose",
descriptionLog: input.description || "",
server: !!compose.serverId,
serverId: compose.serverId ?? undefined,
};
if (IS_CLOUD && compose.serverId) {
jobData.serverId = compose.serverId;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
@@ -479,9 +472,9 @@ export const composeRouter = createTRPCRouter({
applicationType: "compose",
descriptionLog: input.description || "",
server: !!compose.serverId,
serverId: compose.serverId ?? undefined,
};
if (IS_CLOUD && compose.serverId) {
jobData.serverId = compose.serverId;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});

View File

@@ -86,10 +86,10 @@ export const previewDeploymentRouter = createTRPCRouter({
applicationType: "application-preview",
previewDeploymentId: input.previewDeploymentId,
server: !!application.serverId,
serverId: application.serverId ?? undefined,
};
if (IS_CLOUD && application.serverId) {
jobData.serverId = application.serverId;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});

View File

@@ -25,6 +25,7 @@ import { z } from "zod";
import { updateServersBasedOnQuantity } from "@/pages/api/stripe/webhook";
import {
createTRPCRouter,
enterpriseProcedure,
protectedProcedure,
withPermission,
} from "@/server/api/trpc";
@@ -34,6 +35,7 @@ import {
apiFindOneServer,
apiRemoveServer,
apiUpdateServer,
apiUpdateServerBuildsConcurrency,
apiUpdateServerMonitoring,
applications,
compose,
@@ -479,6 +481,20 @@ export const serverRouter = createTRPCRouter({
throw error;
}
}),
updateBuildsConcurrency: enterpriseProcedure
.input(apiUpdateServerBuildsConcurrency)
.mutation(async ({ input, ctx }) => {
const currentServer = await findServerById(input.serverId);
if (currentServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this server",
});
}
return await updateServerById(input.serverId, {
buildsConcurrency: input.buildsConcurrency,
});
}),
publicIp: protectedProcedure.query(async () => {
if (IS_CLOUD) {
return "";

View File

@@ -67,6 +67,7 @@ import {
apiServerSchema,
apiTraefikConfig,
apiUpdateDockerCleanup,
apiUpdateWebServerBuildsConcurrency,
projects,
server,
} from "@/server/db/schema";
@@ -468,6 +469,28 @@ export const settingsRouter = createTRPCRouter({
return true;
}),
updateBuildsConcurrency: enterpriseProcedure
.input(apiUpdateWebServerBuildsConcurrency)
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This feature is only available for self-hosted instances",
});
}
await updateWebServerSettings({
buildsConcurrency: input.buildsConcurrency,
});
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceName: "builds-concurrency",
});
return true;
}),
updateEnforceSSO: enterpriseProcedure
.input(z.object({ enforceSSO: z.boolean() }))
.mutation(async ({ input, ctx }) => {

View File

@@ -0,0 +1,66 @@
import { db } from "@dokploy/server/db";
import { organization, server } from "@dokploy/server/db/schema";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
import { eq } from "drizzle-orm";
import { LOCAL_PARTITION } from "./in-memory-queue";
/**
* Resolve the effective builds concurrency for a queue partition.
*
* Concurrent deployments (concurrency > 1) are an enterprise feature: without a
* valid license the effective concurrency is always clamped to 1, so the
* community experience is unchanged and an expired license degrades gracefully
* back to sequential deployments instead of breaking anything.
*
* - `LOCAL_PARTITION` -> concurrency stored on the web server settings (the
* local Dokploy web server), gated by the owner organization's license.
* - any other partition -> concurrency stored on the matching `server` row,
* gated by that server's organization license.
*/
export const resolveBuildsConcurrency = async (
partition: string,
): Promise<number> => {
try {
if (partition === LOCAL_PARTITION) {
return await resolveLocalConcurrency();
}
return await resolveServerConcurrency(partition);
} catch (error) {
console.error(
"Failed to resolve builds concurrency, defaulting to 1",
error,
);
return 1;
}
};
const clamp = (value: number, licensed: boolean): number => {
if (!licensed) return 1;
return Math.min(20, Math.max(1, value));
};
const resolveLocalConcurrency = async (): Promise<number> => {
const settings = await getWebServerSettings();
const buildsConcurrency = settings?.buildsConcurrency ?? 1;
// Self-hosted is single-tenant; gate on any organization's license.
const anyOrg = await db.query.organization.findFirst({
columns: { id: true },
});
const licensed = anyOrg ? await hasValidLicense(anyOrg.id) : false;
return clamp(buildsConcurrency, licensed);
};
const resolveServerConcurrency = async (serverId: string): Promise<number> => {
const currentServer = await db.query.server.findFirst({
where: eq(server.serverId, serverId),
columns: { buildsConcurrency: true, organizationId: true },
});
if (!currentServer) return 1;
const licensed = await hasValidLicense(currentServer.organizationId);
return clamp(currentServer.buildsConcurrency, licensed);
};

View File

@@ -2,7 +2,6 @@ import {
deployApplication,
deployCompose,
deployPreviewApplication,
IS_CLOUD,
rebuildApplication,
rebuildCompose,
rebuildPreviewApplication,
@@ -10,87 +9,69 @@ import {
updateCompose,
updatePreviewDeployment,
} from "@dokploy/server";
import { type Job, Worker } from "bullmq";
import type { DeploymentJob } from "./queue-types";
import { redisConfig } from "./redis-connection";
import type { InMemoryJob } from "./in-memory-queue";
const createDeploymentWorker = () =>
new Worker(
"deployments",
async (job: Job<DeploymentJob>) => {
try {
if (job.data.applicationType === "application") {
await updateApplicationStatus(job.data.applicationId, "running");
/**
* Processes a single deployment job. Shared by the in-memory queue worker and
* (in cloud) the direct background execution path.
*/
export const processDeploymentJob = async (job: InMemoryJob) => {
try {
if (job.data.applicationType === "application") {
await updateApplicationStatus(job.data.applicationId, "running");
if (job.data.type === "redeploy") {
await rebuildApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
} else if (job.data.type === "deploy") {
await deployApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
}
} else if (job.data.applicationType === "compose") {
await updateCompose(job.data.composeId, {
composeStatus: "running",
});
if (job.data.type === "deploy") {
await deployCompose({
composeId: job.data.composeId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
} else if (job.data.type === "redeploy") {
await rebuildCompose({
composeId: job.data.composeId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
}
} else if (job.data.applicationType === "application-preview") {
await updatePreviewDeployment(job.data.previewDeploymentId, {
previewStatus: "running",
});
if (job.data.type === "redeploy") {
await rebuildPreviewApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
previewDeploymentId: job.data.previewDeploymentId,
});
} else if (job.data.type === "deploy") {
await deployPreviewApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
previewDeploymentId: job.data.previewDeploymentId,
});
}
}
} catch (error) {
console.log("Error", error);
if (job.data.type === "redeploy") {
await rebuildApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
} else if (job.data.type === "deploy") {
await deployApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
}
},
{
autorun: false,
connection: redisConfig,
},
);
} else if (job.data.applicationType === "compose") {
await updateCompose(job.data.composeId, {
composeStatus: "running",
});
if (job.data.type === "deploy") {
await deployCompose({
composeId: job.data.composeId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
} else if (job.data.type === "redeploy") {
await rebuildCompose({
composeId: job.data.composeId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
}
} else if (job.data.applicationType === "application-preview") {
await updatePreviewDeployment(job.data.previewDeploymentId, {
previewStatus: "running",
});
/** No-op worker when Redis is disabled (e.g. IS_CLOUD). Avoids BullMQ connection errors. */
const noopWorker = {
run: () => Promise.resolve(),
close: () => Promise.resolve(),
cancelJob: () => Promise.resolve(),
cancelAllJobs: () => Promise.resolve(),
if (job.data.type === "redeploy") {
await rebuildPreviewApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
previewDeploymentId: job.data.previewDeploymentId,
});
} else if (job.data.type === "deploy") {
await deployPreviewApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
previewDeploymentId: job.data.previewDeploymentId,
});
}
}
} catch (error) {
console.log("Error", error);
}
};
export const deploymentWorker = !IS_CLOUD
? createDeploymentWorker()
: (noopWorker as unknown as Worker<DeploymentJob>);

View File

@@ -0,0 +1,262 @@
import type { DeploymentJob } from "./queue-types";
/**
* In-memory deployment queue for self-hosted instances.
*
* Replaces BullMQ/Redis for deployments. The model is per-group FIFO with a
* configurable concurrency per partition (server):
*
* - Jobs are partitioned by `serverId` (the local web server uses the
* `LOCAL_PARTITION` key). Each partition runs up to `concurrency` jobs at
* the same time, so two different applications can build concurrently.
* - Within a partition, jobs that belong to the same group (same application
* or compose) never run in parallel — they are serialized FIFO. This avoids
* two builds of the same service stepping on each other (same code dir,
* same container name, etc).
*
* The concurrency is resolved lazily per partition through `resolveConcurrency`
* so it can be gated by the enterprise license at run time (a non-licensed
* instance always resolves to 1).
*
* The public surface (`add`, `getJobs`, `close`, `on`) mirrors the subset of
* BullMQ used by the routers so it can be a drop-in replacement.
*/
export const LOCAL_PARTITION = "__local__";
export type JobState = "waiting" | "active";
export interface InMemoryJob {
id: string;
name: string;
data: DeploymentJob;
timestamp: number;
processedOn?: number;
finishedOn?: number;
failedReason?: string;
getState: () => Promise<JobState>;
remove: () => Promise<void>;
}
type Processor = (job: InMemoryJob) => Promise<void>;
/** Resolve the partition key (serverId) a job belongs to. */
export const getPartition = (data: DeploymentJob): string =>
data.serverId ?? LOCAL_PARTITION;
/** Resolve the FIFO group a job belongs to (the service being deployed). */
export const getGroup = (data: DeploymentJob): string => {
if (data.applicationType === "compose") {
return `compose:${data.composeId}`;
}
return `application:${data.applicationId}`;
};
interface InternalJob {
id: string;
name: string;
data: DeploymentJob;
timestamp: number;
processedOn?: number;
finishedOn?: number;
failedReason?: string;
state: JobState;
partition: string;
group: string;
}
interface Partition {
waiting: InternalJob[];
/** Groups currently running in this partition. */
activeGroups: Set<string>;
active: InternalJob[];
}
export interface InMemoryQueueOptions {
/**
* Returns the max number of jobs that may run in parallel for a given
* partition. Called on every scheduling tick so license/config changes are
* picked up without restarting the queue. Must return a value >= 1.
*/
resolveConcurrency: (partition: string) => Promise<number> | number;
/** Monotonic clock; injectable for tests. Defaults to Date.now. */
now?: () => number;
}
export class InMemoryQueue {
private partitions = new Map<string, Partition>();
private processor: Processor | null = null;
private running = false;
private seq = 0;
private readonly resolveConcurrency: InMemoryQueueOptions["resolveConcurrency"];
private readonly now: () => number;
constructor(options: InMemoryQueueOptions) {
this.resolveConcurrency = options.resolveConcurrency;
this.now = options.now ?? (() => Date.now());
}
private getPartitionState(key: string): Partition {
let partition = this.partitions.get(key);
if (!partition) {
partition = { waiting: [], activeGroups: new Set(), active: [] };
this.partitions.set(key, partition);
}
return partition;
}
/**
* Register the worker that processes each job. Registering a processor also
* starts the queue: in dev (tsx/Next) the module that calls `run()` and the
* module that calls `add()` can resolve to different instances, so we must
* not depend on a separate `run()` call to flip `running` on.
*/
process(processor: Processor) {
this.processor = processor;
this.running = true;
this.schedule();
}
run() {
this.running = true;
this.schedule();
return Promise.resolve();
}
async add(data: DeploymentJob): Promise<{ id: string }> {
const id = `job-${++this.seq}`;
const partitionKey = getPartition(data);
const job: InternalJob = {
id,
name: "deployments",
data,
timestamp: this.now(),
state: "waiting",
partition: partitionKey,
group: getGroup(data),
};
this.getPartitionState(partitionKey).waiting.push(job);
this.schedule();
return { id };
}
private toPublic(job: InternalJob): InMemoryJob {
return {
id: job.id,
name: job.name,
data: job.data,
timestamp: job.timestamp,
processedOn: job.processedOn,
finishedOn: job.finishedOn,
getState: () => Promise.resolve(job.state),
remove: () => this.remove(job.id),
};
}
/** Snapshot of jobs in the requested states (defaults to waiting + active). */
getJobs(states?: JobState[]): Promise<InMemoryJob[]> {
const wantWaiting = !states || states.includes("waiting");
const wantActive = !states || states.includes("active");
const jobs: InMemoryJob[] = [];
for (const partition of this.partitions.values()) {
if (wantWaiting) {
jobs.push(...partition.waiting.map((job) => this.toPublic(job)));
}
if (wantActive) {
jobs.push(...partition.active.map((job) => this.toPublic(job)));
}
}
return Promise.resolve(jobs);
}
/** Remove a single waiting job by id. Active jobs cannot be removed. */
remove(id: string): Promise<void> {
for (const partition of this.partitions.values()) {
const before = partition.waiting.length;
partition.waiting = partition.waiting.filter((job) => job.id !== id);
if (partition.waiting.length !== before) break;
}
return Promise.resolve();
}
/** Remove waiting jobs matching a predicate. Active jobs are not affected. */
removeWaiting(predicate: (data: DeploymentJob) => boolean): number {
let removed = 0;
for (const partition of this.partitions.values()) {
partition.waiting = partition.waiting.filter((job) => {
const match = predicate(job.data);
if (match) removed++;
return !match;
});
}
return removed;
}
/** Drop every waiting job across all partitions. */
clearWaiting(): number {
let removed = 0;
for (const partition of this.partitions.values()) {
removed += partition.waiting.length;
partition.waiting = [];
}
return removed;
}
on() {
// No-op: kept for BullMQ API compatibility (error events, etc).
}
close() {
this.running = false;
return Promise.resolve();
}
private schedule() {
if (!this.running || !this.processor) return;
for (const key of this.partitions.keys()) {
void this.drainPartition(key);
}
}
private async drainPartition(key: string) {
const partition = this.partitions.get(key);
if (!partition || !this.processor) return;
const concurrency = Math.max(1, await this.resolveConcurrency(key));
while (partition.active.length < concurrency) {
// First waiting job whose group is not already running.
const index = partition.waiting.findIndex(
(job) => !partition.activeGroups.has(job.group),
);
if (index === -1) break;
const job = partition.waiting.splice(index, 1)[0];
if (!job) break;
job.state = "active";
job.processedOn = this.now();
partition.activeGroups.add(job.group);
partition.active.push(job);
void this.runJob(job);
}
}
private async runJob(job: InternalJob) {
try {
await this.processor?.(this.toPublic(job));
} catch (error) {
job.failedReason = error instanceof Error ? error.message : String(error);
console.error("In-memory deployment job failed", error);
} finally {
job.finishedOn = this.now();
const partition = this.partitions.get(job.partition);
if (partition) {
partition.active = partition.active.filter((j) => j.id !== job.id);
partition.activeGroups.delete(job.group);
}
// A slot (and possibly the group) freed up — try to schedule more.
void this.drainPartition(job.partition);
}
}
}

View File

@@ -3,32 +3,89 @@ import {
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import type { Job } from "bullmq";
import { Queue } from "bullmq";
import { deploymentWorker } from "./deployments-queue";
import { redisConfig } from "./redis-connection";
import { resolveBuildsConcurrency } from "./concurrency";
import { processDeploymentJob } from "./deployments-queue";
import { type InMemoryJob, InMemoryQueue } from "./in-memory-queue";
import type { DeploymentJob } from "./queue-types";
/** No-op queue when Redis is disabled (e.g. IS_CLOUD). Avoids BullMQ connection errors. */
const createNoopQueue = () => ({
getJobs: () => Promise.resolve([] as Job[]),
add: () =>
Promise.resolve({ id: "noop", remove: () => Promise.resolve() } as Job),
/**
* Deployment queue.
*
* Self-hosted uses an in-memory, per-group FIFO queue with configurable
* concurrency per server (enterprise-gated). Cloud does not use the queue at
* all — deployments run directly in the background — so we expose a no-op.
*/
interface DeploymentQueue {
add: (
name: string,
data: DeploymentJob,
opts?: Record<string, unknown>,
) => Promise<{ id: string }>;
getJobs: (states?: Array<"waiting" | "active">) => Promise<InMemoryJob[]>;
close: () => Promise<void>;
on: (...args: unknown[]) => void;
run: () => Promise<void>;
removeWaiting: (predicate: (data: DeploymentJob) => boolean) => number;
clearWaiting: () => number;
}
const createNoopQueue = (): DeploymentQueue => ({
add: () => Promise.resolve({ id: "noop" }),
getJobs: () => Promise.resolve([]),
close: () => Promise.resolve(),
on: () => {},
run: () => Promise.resolve(),
removeWaiting: () => 0,
clearWaiting: () => 0,
});
const myQueue = !IS_CLOUD
? new Queue("deployments", { connection: redisConfig })
: (createNoopQueue() as unknown as Queue);
const createInMemoryQueue = (): DeploymentQueue => {
const queue = new InMemoryQueue({
resolveConcurrency: resolveBuildsConcurrency,
});
queue.process(processDeploymentJob);
return {
add: (_name, data) => queue.add(data),
getJobs: (states) => queue.getJobs(states),
close: () => queue.close(),
on: () => {},
run: () => queue.run(),
removeWaiting: (predicate) => queue.removeWaiting(predicate),
clearWaiting: () => queue.clearWaiting(),
};
};
// Use a global singleton so the deployment queue is shared across every module
// instance. In dev (tsx/Next) the same file can be evaluated more than once
// (relative import in server.ts vs `@/` alias in the routers); without this the
// worker and the `add()` calls would land on different queue instances.
const globalForQueue = globalThis as unknown as {
__dokployDeploymentQueue?: DeploymentQueue;
};
if (!globalForQueue.__dokployDeploymentQueue) {
globalForQueue.__dokployDeploymentQueue = !IS_CLOUD
? createInMemoryQueue()
: createNoopQueue();
}
const myQueue: DeploymentQueue = globalForQueue.__dokployDeploymentQueue;
/** Start processing jobs. Called once on server startup (self-hosted). */
export const startDeploymentWorker = () => myQueue.run();
export const getJobsByApplicationId = async (applicationId: string) => {
const jobs = await myQueue.getJobs();
return jobs.filter((job) => job?.data?.applicationId === applicationId);
return jobs.filter(
(job) => (job.data as any)?.applicationId === applicationId,
);
};
export const getJobsByComposeId = async (composeId: string) => {
const jobs = await myQueue.getJobs();
return jobs.filter((job) => job?.data?.composeId === composeId);
return jobs.filter((job) => (job.data as any)?.composeId === composeId);
};
if (!IS_CLOUD) {
@@ -36,44 +93,33 @@ if (!IS_CLOUD) {
myQueue.close();
process.exit(0);
});
myQueue.on("error", (error) => {
if ((error as any).code === "ECONNREFUSED") {
console.error(
"Make sure you have installed Redis and it is running.",
error,
);
}
});
}
export const cleanQueuesByApplication = async (applicationId: string) => {
const jobs = await myQueue.getJobs(["waiting", "delayed"]);
const removed = myQueue.removeWaiting(
(data) => (data as any)?.applicationId === applicationId,
);
if (removed > 0) {
console.log(
`Removed ${removed} waiting job(s) for application ${applicationId}`,
);
}
};
for (const job of jobs) {
if (job?.data?.applicationId === applicationId) {
await job.remove();
console.log(`Removed job ${job.id} for application ${applicationId}`);
}
export const cleanQueuesByCompose = async (composeId: string) => {
const removed = myQueue.removeWaiting(
(data) => (data as any)?.composeId === composeId,
);
if (removed > 0) {
console.log(`Removed ${removed} waiting job(s) for compose ${composeId}`);
}
};
export const cleanAllDeploymentQueue = async () => {
deploymentWorker.cancelAllJobs("User requested cancellation");
myQueue.clearWaiting();
return true;
};
export const cleanQueuesByCompose = async (composeId: string) => {
const jobs = await myQueue.getJobs(["waiting", "delayed"]);
for (const job of jobs) {
if (job?.data?.composeId === composeId) {
await job.remove();
console.log(`Removed job ${job.id} for compose ${composeId}`);
}
}
};
export const killDockerBuild = async (
type: "application" | "compose",
serverId: string | null,

View File

@@ -71,8 +71,8 @@ void app.prepare().then(async () => {
if (!IS_CLOUD) {
console.log("Starting Deployment Worker");
const { deploymentWorker } = await import("./queues/deployments-queue");
await deploymentWorker.run();
const { startDeploymentWorker } = await import("./queues/queueSetup");
await startDeploymentWorker();
}
} catch (e) {
console.error("Main Server Error", e);

View File

@@ -41,6 +41,7 @@ export const server = pgTable("server", {
.notNull()
.$defaultFn(() => generateAppName("server")),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
buildsConcurrency: integer("buildsConcurrency").notNull().default(1),
createdAt: text("createdAt").notNull(),
organizationId: text("organizationId")
.notNull()
@@ -182,6 +183,11 @@ export const apiUpdateServer = createSchema
enableDockerCleanup: z.boolean().default(true),
});
export const apiUpdateServerBuildsConcurrency = z.object({
serverId: z.string().min(1),
buildsConcurrency: z.number().int().min(1).max(20),
});
export const apiUpdateServerMonitoring = createSchema
.pick({
serverId: true,

View File

@@ -1,5 +1,12 @@
import { relations } from "drizzle-orm";
import { boolean, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import {
boolean,
integer,
jsonb,
pgTable,
text,
timestamp,
} from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
@@ -98,6 +105,8 @@ export const webServerSettings = pgTable("webServerSettings", {
}),
// Deployment Configuration (self-hosted only)
remoteServersOnly: boolean("remoteServersOnly").notNull().default(false),
// Concurrent builds on the local web server (enterprise-gated to > 1)
buildsConcurrency: integer("buildsConcurrency").notNull().default(1),
// Auth Configuration (self-hosted only)
enforceSSO: boolean("enforceSSO").notNull().default(false),
// Cache Cleanup Configuration
@@ -161,6 +170,11 @@ export const apiUpdateWebServerSettings = createSchema.partial().extend({
cleanupCacheOnCompose: z.boolean().optional(),
remoteServersOnly: z.boolean().optional(),
enforceSSO: z.boolean().optional(),
buildsConcurrency: z.number().int().min(1).max(20).optional(),
});
export const apiUpdateWebServerBuildsConcurrency = z.object({
buildsConcurrency: z.number().int().min(1).max(20),
});
export const apiAssignDomain = z

View File

@@ -44,10 +44,15 @@ export const getRailpackCommand = (application: ApplicationNested) => {
const secretsHash = calculateSecretsHash(envVariables);
const cacheKey = cleanCache ? nanoid(10) : undefined;
// Build command
// Build command.
// Use a unique builder name per build so concurrent deployments don't race
// on a shared "builder-containerd" instance (create/use/rm collisions).
const builderName = `railpack-${appName}-${nanoid(6)}`;
const buildArgs = [
"buildx",
"build",
"--builder",
builderName,
...(cacheKey
? [
"--build-arg",
@@ -84,17 +89,16 @@ export const getRailpackCommand = (application: ApplicationNested) => {
const bashCommand = `
# Ensure we have a builder with containerd
# Ensure we have a builder with containerd (isolated per build)
export RAILPACK_VERSION=${application.railpackVersion}
bash -c "$(curl -fsSL https://railpack.com/install.sh)"
docker buildx create --use --name builder-containerd --driver docker-container || true
docker buildx use builder-containerd
docker buildx create --name ${builderName} --driver docker-container || true
echo "Preparing Railpack build plan..." ;
railpack ${prepareArgs.join(" ")} || {
railpack ${prepareArgs.join(" ")} || {
echo "❌ Railpack prepare failed" ;
docker buildx rm builder-containerd || true
docker buildx rm ${builderName} || true
exit 1;
}
echo "✅ Railpack prepare completed." ;
@@ -102,13 +106,13 @@ echo "✅ Railpack prepare completed." ;
echo "Building with Railpack frontend..." ;
# Export environment variables for secrets
${exportEnvs.join("\n")}
docker ${buildArgs.join(" ")} || {
docker ${buildArgs.join(" ")} || {
echo "❌ Railpack build failed" ;
docker buildx rm builder-containerd || true
docker buildx rm ${builderName} || true
exit 1;
}
echo "✅ Railpack build completed." ;
docker buildx rm builder-containerd
docker buildx rm ${builderName} || true
`;
return bashCommand;

View File

@@ -1,5 +1,5 @@
import { deployments } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm";
import { applications, compose, deployments } from "@dokploy/server/db/schema";
import { eq, inArray } from "drizzle-orm";
import { db } from "../../db/index";
export const initCancelDeployments = async () => {
@@ -14,6 +14,36 @@ export const initCancelDeployments = async () => {
.where(eq(deployments.status, "running"))
.returning();
// Reset the related services so they don't stay stuck in "running".
const applicationIds = [
...new Set(
result
.map((deployment) => deployment.applicationId)
.filter((id): id is string => !!id),
),
];
const composeIds = [
...new Set(
result
.map((deployment) => deployment.composeId)
.filter((id): id is string => !!id),
),
];
if (applicationIds.length > 0) {
await db
.update(applications)
.set({ applicationStatus: "idle" })
.where(inArray(applications.applicationId, applicationIds));
}
if (composeIds.length > 0) {
await db
.update(compose)
.set({ composeStatus: "idle" })
.where(inArray(compose.composeId, composeIds));
}
console.log(`Cancelled ${result.length} deployments`);
} catch (error) {
console.error(error);