Compare commits

..

1 Commits

Author SHA1 Message Date
Mauricio Siu
29f30e3386 fix: enforce docker:read on container start/stop/kill/restart mutations 2026-06-07 00:18:12 -06:00
47 changed files with 554 additions and 10697 deletions

View File

@@ -1,42 +0,0 @@
---
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

@@ -1,369 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
canEditDeployGitSource,
getAccessibleGitProviderIds,
} from "@dokploy/server/services/git-provider";
const mockDb = vi.hoisted(() => ({
query: {
gitProvider: {
findMany: vi.fn(),
findFirst: vi.fn(),
},
member: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@dokploy/server/db", () => ({ db: mockDb }));
const mockHasValidLicense = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: mockHasValidLicense,
}));
const ORG_ID = "org-1";
const USER_OWNER = "user-owner";
const USER_ADMIN = "user-admin";
const USER_MEMBER = "user-member";
const USER_MEMBER_2 = "user-member-2";
const providerOwned = {
gitProviderId: "gp-owned",
userId: USER_MEMBER,
sharedWithOrganization: false,
};
const providerShared = {
gitProviderId: "gp-shared",
userId: USER_OWNER,
sharedWithOrganization: true,
};
const providerPrivate = {
gitProviderId: "gp-private",
userId: USER_OWNER,
sharedWithOrganization: false,
};
const providerOtherMember = {
gitProviderId: "gp-other",
userId: USER_MEMBER_2,
sharedWithOrganization: false,
};
const allProviders = [
providerOwned,
providerShared,
providerPrivate,
providerOtherMember,
];
function session(userId: string) {
return { userId, activeOrganizationId: ORG_ID };
}
beforeEach(() => {
vi.clearAllMocks();
mockDb.query.gitProvider.findMany.mockResolvedValue(allProviders);
mockHasValidLicense.mockResolvedValue(false);
});
describe("getAccessibleGitProviderIds", () => {
describe("owner", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "owner",
accessedGitProviders: [],
});
});
it("returns all org providers", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_OWNER));
expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId)));
});
it("includes providers owned by other members", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_OWNER));
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
expect(ids.has(providerOtherMember.gitProviderId)).toBe(true);
});
});
describe("admin", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "admin",
accessedGitProviders: [],
});
});
it("returns all org providers", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId)));
});
it("includes providers owned by other members — fixes issue #4469", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
expect(ids.has(providerOtherMember.gitProviderId)).toBe(true);
});
});
describe("member without enterprise license", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [providerPrivate.gitProviderId],
});
mockHasValidLicense.mockResolvedValue(false);
});
it("can access their own provider", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
});
it("can access shared providers", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerShared.gitProviderId)).toBe(true);
});
it("cannot access private providers of other users even if assigned (no license)", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
});
it("cannot access providers of other members", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
});
});
describe("member with enterprise license", () => {
beforeEach(() => {
mockHasValidLicense.mockResolvedValue(true);
});
it("can access provider explicitly assigned to them", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [providerPrivate.gitProviderId],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
});
it("cannot access provider not assigned and not shared", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
});
it("can access shared provider even without explicit assignment", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerShared.gitProviderId)).toBe(true);
});
it("can access own provider regardless of assignments", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
});
it("cannot access provider of other member even with license but no assignment", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
});
});
describe("member with no member record", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue(null);
mockHasValidLicense.mockResolvedValue(true);
});
it("only returns own providers and shared ones", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
expect(ids.has(providerShared.gitProviderId)).toBe(true);
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
});
});
describe("enterprise license — member assigned to a provider they do not own", () => {
// getAccessibleGitProviderIds still returns the provider (member can connect NEW deploys)
it("member assigned to owner's private provider can USE the provider for new deploys", async () => {
mockHasValidLicense.mockResolvedValue(true);
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [providerPrivate.gitProviderId],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
});
it("member NOT assigned to owner's private provider cannot use it at all", async () => {
mockHasValidLicense.mockResolvedValue(true);
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
});
});
describe("empty org", () => {
beforeEach(() => {
mockDb.query.gitProvider.findMany.mockResolvedValue([]);
mockDb.query.member.findFirst.mockResolvedValue({
role: "admin",
accessedGitProviders: [],
});
});
it("returns empty set when org has no providers", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
expect(ids.size).toBe(0);
});
});
});
describe("canEditDeployGitSource", () => {
beforeEach(() => {
vi.clearAllMocks();
mockHasValidLicense.mockResolvedValue(true);
});
describe("owner", () => {
it("can edit deploy using any provider", async () => {
mockDb.query.member.findFirst.mockResolvedValue({ role: "owner" });
const result = await canEditDeployGitSource(
providerPrivate.gitProviderId,
session(USER_OWNER),
);
expect(result).toBe(true);
});
});
describe("admin", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({ role: "admin" });
});
it("cannot edit deploy using owner's private provider (not shared)", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_OWNER,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
providerPrivate.gitProviderId,
session(USER_ADMIN),
);
expect(result).toBe(false);
});
it("can edit deploy using a provider shared with the org", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_OWNER,
sharedWithOrganization: true,
});
const result = await canEditDeployGitSource(
providerShared.gitProviderId,
session(USER_ADMIN),
);
expect(result).toBe(true);
});
it("can edit deploy using their own provider", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_ADMIN,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
"gp-admin-owned",
session(USER_ADMIN),
);
expect(result).toBe(true);
});
});
describe("member", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({ role: "member" });
});
it("can edit deploy using their own provider", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_MEMBER,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
providerOwned.gitProviderId,
session(USER_MEMBER),
);
expect(result).toBe(true);
});
it("can edit deploy using a provider shared with the org", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_OWNER,
sharedWithOrganization: true,
});
const result = await canEditDeployGitSource(
providerShared.gitProviderId,
session(USER_MEMBER),
);
expect(result).toBe(true);
});
it("cannot edit deploy using owner's private provider even with enterprise license and assignment", async () => {
// This is the key case: enterprise, provider del owner, no compartido,
// member tiene accessedGitProviders asignado — pero NO puede cambiar la branch del deploy del owner
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_OWNER,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
providerPrivate.gitProviderId,
session(USER_MEMBER),
);
expect(result).toBe(false);
});
it("cannot edit deploy using another member's private provider", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_MEMBER_2,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
providerOtherMember.gitProviderId,
session(USER_MEMBER),
);
expect(result).toBe(false);
});
it("returns false if provider does not exist", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue(null);
const result = await canEditDeployGitSource(
"nonexistent-id",
session(USER_MEMBER),
);
expect(result).toBe(false);
});
});
});

View File

@@ -1,148 +0,0 @@
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 {
assertBuildsConcurrencyAllowed,
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 the free max (2) when there is no valid license", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 10 });
hasValidLicense.mockResolvedValue(false);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(2);
});
it("allows the free max (2) without a license", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 2 });
hasValidLicense.mockResolvedValue(false);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(2);
});
it("does not cap the value when licensed (N allowed)", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 999 });
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(
999,
);
});
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 the free max (2) 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(2);
});
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);
});
});
describe("assertBuildsConcurrencyAllowed", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("allows up to the free max (2) without checking the license", async () => {
await expect(
assertBuildsConcurrencyAllowed(2, "org-1"),
).resolves.toBeUndefined();
expect(hasValidLicense).not.toHaveBeenCalled();
});
it("allows more than 2 when licensed", async () => {
hasValidLicense.mockResolvedValue(true);
await expect(
assertBuildsConcurrencyAllowed(5, "org-1"),
).resolves.toBeUndefined();
});
it("rejects more than 2 without a license", async () => {
hasValidLicense.mockResolvedValue(false);
await expect(assertBuildsConcurrencyAllowed(3, "org-1")).rejects.toThrow(
/enterprise license/i,
);
});
});

View File

@@ -1,337 +0,0 @@
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

@@ -226,8 +226,8 @@ describe("deriveCookieSecret", () => {
);
});
test("produces a 16-byte hex secret (oauth2-proxy requirement)", () => {
test("produces a 32-byte base64 secret (oauth2-proxy requirement)", () => {
const secret = deriveCookieSecret(".acme.com");
expect(Buffer.from(secret, "hex")).toHaveLength(16);
expect(Buffer.from(secret, "base64")).toHaveLength(32);
});
});

View File

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

View File

@@ -1,11 +1,4 @@
import {
FileIcon,
Folder,
FolderOpen,
Loader2,
MousePointerClick,
Workflow,
} from "lucide-react";
import { FileIcon, Folder, Loader2, Workflow } from "lucide-react";
import React from "react";
import { AlertBlock } from "@/components/shared/alert-block";
import {
@@ -75,22 +68,12 @@ export const ShowTraefikSystem = ({ serverId }: Props) => {
</div>
)}
{directories?.length === 0 && (
<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 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>
)}
{directories && directories?.length > 0 && (
@@ -106,19 +89,11 @@ export const ShowTraefikSystem = ({ serverId }: Props) => {
{file ? (
<ShowTraefikFile path={file} serverId={serverId} />
) : (
<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 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>
)}
</div>

View File

@@ -0,0 +1,30 @@
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

@@ -134,10 +134,15 @@ export const ShowGitProviders = () => {
const canManage = gitProvider.isOwner || isOrgAdmin;
const haveGithubRequirements =
isGithub && gitProvider.github?.isConfigured;
isGithub &&
gitProvider.github?.githubPrivateKey &&
gitProvider.github?.githubAppId &&
gitProvider.github?.githubInstallationId;
const haveGitlabRequirements =
isGitlab && gitProvider.gitlab?.isConfigured;
isGitlab &&
gitProvider.gitlab?.accessToken &&
gitProvider.gitlab?.refreshToken;
return (
<div
@@ -225,7 +230,8 @@ export const ShowGitProviders = () => {
)}
{isBitbucket &&
gitProvider.bitbucket?.isDeprecated ? (
gitProvider.bitbucket?.appPassword &&
!gitProvider.bitbucket?.apiToken ? (
<Badge variant="yellow">Deprecated</Badge>
) : null}
@@ -238,7 +244,7 @@ export const ShowGitProviders = () => {
Action Required
</Badge>
<Link
href={`${gitProvider?.github?.githubAppName}/installations/new?state=gh_setup:${gitProvider?.github?.githubId}`}
href={`${gitProvider?.github?.githubAppName}/installations/new?state=gh_setup:${gitProvider?.github.githubId}`}
className={buttonVariants({
size: "icon",
variant: "ghost",
@@ -274,7 +280,7 @@ export const ShowGitProviders = () => {
href={getGitlabUrl(
gitProvider.gitlab?.applicationId || "",
gitProvider.gitlab?.gitlabId || "",
gitProvider.gitlab?.gitlabUrl || "",
gitProvider.gitlab?.gitlabUrl,
)}
target="_blank"
className={buttonVariants({
@@ -289,33 +295,29 @@ export const ShowGitProviders = () => {
{canManage && (
<>
{isGithub &&
haveGithubRequirements &&
gitProvider.github?.githubId && (
<EditGithubProvider
githubId={gitProvider.github.githubId}
/>
)}
{isGithub && haveGithubRequirements && (
<EditGithubProvider
githubId={gitProvider.github?.githubId}
/>
)}
{isGitlab &&
gitProvider.gitlab?.gitlabId && (
<EditGitlabProvider
gitlabId={gitProvider.gitlab.gitlabId}
/>
)}
{isGitlab && (
<EditGitlabProvider
gitlabId={gitProvider.gitlab?.gitlabId}
/>
)}
{isBitbucket &&
gitProvider.bitbucket?.bitbucketId && (
<EditBitbucketProvider
bitbucketId={
gitProvider.bitbucket.bitbucketId
}
/>
)}
{isBitbucket && (
<EditBitbucketProvider
bitbucketId={
gitProvider.bitbucket?.bitbucketId
}
/>
)}
{isGitea && gitProvider.gitea?.giteaId && (
{isGitea && (
<EditGiteaProvider
giteaId={gitProvider.gitea.giteaId}
giteaId={gitProvider.gitea?.giteaId}
/>
)}

View File

@@ -1,122 +0,0 @@
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";
// Free tier may set up to 2 concurrent builds; enterprise unlocks more.
const FREE_MAX_CONCURRENCY = 2;
const ENTERPRISE_MAX_CONCURRENCY = 100;
interface Props {
/**
* When provided, configures concurrency for that remote server. When
* omitted, configures the local Dokploy web server.
*/
serverId?: string;
/** Optional title override (e.g. the server name in a list). */
label?: string;
}
/**
* Control to set the number of concurrent builds, either for a remote server
* (`serverId` provided) or the local web server (omitted). Available to
* everyone self-hosted up to FREE_MAX_CONCURRENCY; higher values require a
* valid enterprise license. Not shown in cloud.
*/
export const BuildsConcurrency = ({ serverId, label }: 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 feature; not shown in cloud.
if (isCloud) return null;
const max = haveValidLicense
? ENTERPRISE_MAX_CONCURRENCY
: FREE_MAX_CONCURRENCY;
const clamp = (n: number) => Math.min(max, Math.max(1, n));
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-col gap-3 rounded-lg border p-3">
<div className="flex flex-row items-center justify-between gap-4">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">
{label ?? serverQuery.data?.name ?? "Dokploy Server"}
</p>
<span className="text-xs text-muted-foreground rounded border px-1.5 py-0.5">
{serverId
? (serverQuery.data?.ipAddress ?? "remote server")
: "local host"}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={max}
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>
</div>
);
};

View File

@@ -0,0 +1,30 @@
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,7 +1,6 @@
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 {
@@ -15,9 +14,12 @@ export const ShowMonitoringModal = ({ url, token }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<BarChartHorizontalBigIcon className="h-4 w-4" />
</Button>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Monitoring
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-7xl ">
<div className="flex gap-4 py-4 w-full">

View File

@@ -0,0 +1,28 @@
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,6 +4,7 @@ import {
Key,
KeyIcon,
Loader2,
MoreHorizontal,
Network,
ServerIcon,
Terminal,
@@ -24,6 +25,12 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
@@ -31,11 +38,16 @@ 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 = () => {
@@ -126,6 +138,52 @@ 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">
@@ -303,27 +361,6 @@ 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

@@ -0,0 +1,48 @@
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

@@ -0,0 +1,28 @@
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,31 +182,36 @@ const MENU: Menu = {
title: "Schedules",
url: "/dashboard/schedules",
icon: Clock,
isEnabled: ({ permissions }) => !!permissions?.organization.update,
// Only enabled in non-cloud environments
isEnabled: ({ isCloud, permissions }) =>
!isCloud && !!permissions?.organization.update,
},
{
isSingle: true,
title: "Traefik File System",
url: "/dashboard/traefik",
icon: GalleryVerticalEnd,
// Only enabled for users with access to Traefik files
isEnabled: ({ permissions }) => !!permissions?.traefikFiles.read,
// Only enabled for users with access to Traefik files in non-cloud environments
isEnabled: ({ permissions, isCloud }) =>
!!(permissions?.traefikFiles.read && !isCloud),
},
{
isSingle: true,
title: "Docker",
url: "/dashboard/docker",
icon: BlocksIcon,
// Only enabled for users with access to Docker
isEnabled: ({ permissions }) => !!permissions?.docker.read,
// Only enabled for users with access to Docker in non-cloud environments
isEnabled: ({ permissions, isCloud }) =>
!!(permissions?.docker.read && !isCloud),
},
{
isSingle: true,
title: "Swarm",
url: "/dashboard/swarm",
icon: PieChart,
// Only enabled for users with access to Docker
isEnabled: ({ permissions }) => !!permissions?.docker.read,
// Only enabled for users with access to Docker in non-cloud environments
isEnabled: ({ permissions, isCloud }) =>
!!(permissions?.docker.read && !isCloud),
},
{
isSingle: true,
@@ -298,14 +303,6 @@ const MENU: Menu = {
icon: Server,
isEnabled: ({ permissions }) => !!permissions?.server.read,
},
{
isSingle: true,
title: "Deployments",
url: "/dashboard/settings/deployments",
icon: Boxes,
isEnabled: ({ permissions, isCloud }) =>
!!(permissions?.server.read && !isCloud),
},
{
isSingle: true,
title: "Users",
@@ -378,8 +375,9 @@ const MENU: Menu = {
title: "Cluster",
url: "/dashboard/settings/cluster",
icon: Boxes,
// Only enabled for admins
isEnabled: ({ permissions }) => !!permissions?.organization.update,
// Only enabled for admins in non-cloud environments
isEnabled: ({ permissions, isCloud }) =>
!!(permissions?.organization.update && !isCloud),
},
{
isSingle: true,

View File

@@ -1,156 +0,0 @@
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

@@ -1,2 +0,0 @@
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,13 +1205,6 @@
"when": 1780775037209,
"tag": "0171_lucky_echo",
"breakpoints": true
},
{
"idx": 172,
"version": "7",
"when": 1781045439162,
"tag": "0172_quick_the_professor",
"breakpoints": true
}
]
}

View File

@@ -1,3 +1,4 @@
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";
@@ -5,15 +6,10 @@ 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 (
<ServerFilter>
{(serverId) => <ShowContainers serverId={serverId} />}
</ServerFilter>
);
return <ShowContainers />;
};
export default Dashboard;
@@ -24,6 +20,14 @@ 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,27 +1,20 @@
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 (
<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 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" />
</div>
)}
</ServerFilter>
</Card>
</div>
);
}
export default SchedulesPage;
@@ -33,6 +26,14 @@ 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,22 +1,17 @@
import { validateRequest } from "@dokploy/server";
import { IS_CLOUD, 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 (
<ServerFilter>
{(serverId) => (
<div className="flex flex-col gap-4 w-full">
<ShowNodes serverId={serverId} />
</div>
)}
</ServerFilter>
<div className="flex flex-col gap-4 w-full">
<ShowNodes />
</div>
);
};
@@ -29,6 +24,14 @@ 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

@@ -1,134 +0,0 @@
import { IS_CLOUD, 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 { BuildsConcurrency } from "@/components/dashboard/settings/servers/actions/builds-concurrency";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AlertBlock } from "@/components/shared/alert-block";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
const Page = () => {
const { data: servers } = api.server.all.useQuery();
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl">Concurrent Builds</CardTitle>
<CardDescription>
Configure how many deployments can build at the same time on
each server. Builds of the same service are always serialized.
Free plan allows up to 2 concurrent builds; an enterprise
license unlocks more.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<AlertBlock type="warning">
Running multiple builds at once increases CPU, memory and disk
usage on each server. Each concurrent build runs its own builder
and image build, so set this based on the resources the machine
can handle too high a value can exhaust memory and make
deployments fail.
</AlertBlock>
<div className="flex flex-col gap-2">
<p className="text-sm font-medium text-muted-foreground">
Dokploy Server
</p>
<BuildsConcurrency />
</div>
<div className="flex flex-col gap-2">
<p className="text-sm font-medium text-muted-foreground">
Remote Servers
</p>
{servers && servers.length > 0 ? (
<div className="flex flex-col gap-3">
{servers.map((server) => (
<BuildsConcurrency
key={server.serverId}
serverId={server.serverId}
label={server.name}
/>
))}
</div>
) : (
<p className="text-sm text-muted-foreground rounded-lg border border-dashed p-4 text-center">
No remote servers added yet.
</p>
)}
</div>
</CardContent>
</div>
</Card>
</div>
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Deployments">{page}</DashboardLayout>;
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { req, res } = ctx;
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
if (user.role === "member") {
return {
redirect: {
permanent: false,
destination: "/dashboard/settings/profile",
},
};
}
// Concurrent builds is a self-hosted feature only.
if (IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/settings/profile",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
await helpers.server.all.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
isCloud: IS_CLOUD,
},
};
}

View File

@@ -1,3 +1,4 @@
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,35 +7,30 @@ 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 (
<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>
<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>
);
};
@@ -46,6 +42,14 @@ 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,3 +1,4 @@
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";
@@ -5,15 +6,10 @@ 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 (
<ServerFilter>
{(serverId) => <ShowTraefikSystem serverId={serverId} />}
</ServerFilter>
);
return <ShowTraefikSystem />;
};
export default Dashboard;
@@ -24,6 +20,14 @@ 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

@@ -4,6 +4,7 @@ import {
deleteAllMiddlewares,
findApplicationById,
findEnvironmentById,
findGitProviderById,
findProjectById,
getAccessibleServerIds,
getApplicationStats,
@@ -30,7 +31,6 @@ import {
writeConfigRemote,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { canEditDeployGitSource } from "@dokploy/server/services/git-provider";
import {
addNewService,
checkServiceAccess,
@@ -68,9 +68,11 @@ 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";
@@ -172,11 +174,13 @@ export const applicationRouter = createTRPCRouter({
const gitProviderId = getGitProviderId();
if (gitProviderId) {
const canEdit = await canEditDeployGitSource(
gitProviderId,
ctx.session,
);
if (!canEdit) {
try {
const gitProvider = await findGitProviderById(gitProviderId);
if (gitProvider.userId !== ctx.session.userId) {
hasGitProviderAccess = false;
unauthorizedProvider = application.sourceType;
}
} catch {
hasGitProviderAccess = false;
unauthorizedProvider = application.sourceType;
}
@@ -240,7 +244,12 @@ export const applicationRouter = createTRPCRouter({
.returning();
if (!IS_CLOUD) {
await cleanQueuesByApplication(input.applicationId);
const queueJobs = await getJobsByApplicationId(input.applicationId);
for (const job of queueJobs) {
if (job.id) {
deploymentWorker.cancelJob(job.id, "User requested cancellation");
}
}
}
const cleanupOperations = [
@@ -332,10 +341,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);
});
@@ -700,9 +709,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);
});
@@ -819,9 +828,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

@@ -13,6 +13,7 @@ import {
findComposeById,
findDomainsByComposeId,
findEnvironmentById,
findGitProviderById,
findProjectById,
findServerById,
getAccessibleServerIds,
@@ -33,7 +34,6 @@ import {
updateDeploymentStatus,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { canEditDeployGitSource } from "@dokploy/server/services/git-provider";
import {
addNewService,
checkServiceAccess,
@@ -68,9 +68,11 @@ 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";
@@ -171,11 +173,13 @@ export const composeRouter = createTRPCRouter({
const gitProviderId = getGitProviderId();
if (gitProviderId) {
const canEdit = await canEditDeployGitSource(
gitProviderId,
ctx.session,
);
if (!canEdit) {
try {
const gitProvider = await findGitProviderById(gitProviderId);
if (gitProvider.userId !== ctx.session.userId) {
hasGitProviderAccess = false;
unauthorizedProvider = compose.sourceType;
}
} catch {
hasGitProviderAccess = false;
unauthorizedProvider = compose.sourceType;
}
@@ -250,7 +254,12 @@ export const composeRouter = createTRPCRouter({
.returning();
if (!IS_CLOUD) {
await cleanQueuesByCompose(input.composeId);
const queueJobs = await getJobsByComposeId(input.composeId);
for (const job of queueJobs) {
if (job.id) {
deploymentWorker.cancelJob(job.id, "User requested cancellation");
}
}
}
const cleanupOperations = [
@@ -423,10 +432,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);
});
@@ -472,9 +481,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

@@ -42,43 +42,6 @@ export const gitProviderRouter = createTRPCRouter({
return results.map((r) => ({
...r,
isOwner: r.userId === ctx.session.userId,
github: r.github
? {
githubId: r.github.githubId,
githubAppName: r.github.githubAppName,
githubAppId: r.github.githubAppId,
githubInstallationId: r.github.githubInstallationId,
isConfigured: !!(
r.github.githubPrivateKey &&
r.github.githubAppId &&
r.github.githubInstallationId
),
}
: null,
gitlab: r.gitlab
? {
gitlabId: r.gitlab.gitlabId,
applicationId: r.gitlab.applicationId,
gitlabUrl: r.gitlab.gitlabUrl,
isConfigured: !!(r.gitlab.accessToken && r.gitlab.refreshToken),
}
: null,
bitbucket: r.bitbucket
? {
bitbucketId: r.bitbucket.bitbucketId,
bitbucketUsername: r.bitbucket.bitbucketUsername,
isConfigured: false,
isDeprecated: !!(r.bitbucket.appPassword && !r.bitbucket.apiToken),
}
: null,
gitea: r.gitea
? {
giteaId: r.gitea.giteaId,
giteaUrl: r.gitea.giteaUrl,
clientId: r.gitea.clientId,
isConfigured: !!(r.gitea.accessToken && r.gitea.refreshToken),
}
: null,
}));
}),

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

@@ -34,7 +34,6 @@ import {
apiFindOneServer,
apiRemoveServer,
apiUpdateServer,
apiUpdateServerBuildsConcurrency,
apiUpdateServerMonitoring,
applications,
compose,
@@ -46,7 +45,6 @@ import {
redis,
server,
} from "@/server/db/schema";
import { assertBuildsConcurrencyAllowed } from "@/server/queues/concurrency";
import { applyDockerCleanupSchedule } from "@/server/utils/docker-cleanup";
export const serverRouter = createTRPCRouter({
@@ -481,24 +479,6 @@ export const serverRouter = createTRPCRouter({
throw error;
}
}),
updateBuildsConcurrency: withPermission("server", "create")
.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",
});
}
await assertBuildsConcurrencyAllowed(
input.buildsConcurrency,
ctx.session.activeOrganizationId,
);
return await updateServerById(input.serverId, {
buildsConcurrency: input.buildsConcurrency,
});
}),
publicIp: protectedProcedure.query(async () => {
if (IS_CLOUD) {
return "";

View File

@@ -67,11 +67,9 @@ import {
apiServerSchema,
apiTraefikConfig,
apiUpdateDockerCleanup,
apiUpdateWebServerBuildsConcurrency,
projects,
server,
} from "@/server/db/schema";
import { assertBuildsConcurrencyAllowed } from "@/server/queues/concurrency";
import { cleanAllDeploymentQueue } from "@/server/queues/queueSetup";
import { removeJob, schedule } from "@/server/utils/backup";
import packageInfo from "../../../package.json";
@@ -470,33 +468,6 @@ export const settingsRouter = createTRPCRouter({
return true;
}),
updateBuildsConcurrency: adminProcedure
.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 assertBuildsConcurrencyAllowed(
input.buildsConcurrency,
ctx.session.activeOrganizationId,
);
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

@@ -1,90 +0,0 @@
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 { TRPCError } from "@trpc/server";
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;
}
};
// Max concurrent builds allowed without an enterprise license. With a valid
// license the value is unbounded (N) — only the free tier is capped.
export const FREE_MAX_CONCURRENCY = 2;
const clamp = (value: number, licensed: boolean): number => {
const min = Math.max(1, Math.floor(value));
return licensed ? min : Math.min(FREE_MAX_CONCURRENCY, min);
};
/**
* Validate a requested builds-concurrency value before persisting it. Free tier
* may set up to FREE_MAX_CONCURRENCY; anything higher requires a valid
* enterprise license. Throws a TRPCError when the value is not allowed.
*/
export const assertBuildsConcurrencyAllowed = async (
value: number,
organizationId: string,
): Promise<void> => {
if (value <= FREE_MAX_CONCURRENCY) return;
const licensed = await hasValidLicense(organizationId);
if (!licensed) {
throw new TRPCError({
code: "FORBIDDEN",
message: `A valid enterprise license is required to set more than ${FREE_MAX_CONCURRENCY} concurrent builds.`,
});
}
};
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,6 +2,7 @@ import {
deployApplication,
deployCompose,
deployPreviewApplication,
IS_CLOUD,
rebuildApplication,
rebuildCompose,
rebuildPreviewApplication,
@@ -9,69 +10,87 @@ import {
updateCompose,
updatePreviewDeployment,
} from "@dokploy/server";
import type { InMemoryJob } from "./in-memory-queue";
import { type Job, Worker } from "bullmq";
import type { DeploymentJob } from "./queue-types";
import { redisConfig } from "./redis-connection";
/**
* 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");
const createDeploymentWorker = () =>
new Worker(
"deployments",
async (job: Job<DeploymentJob>) => {
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 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,
});
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);
}
}
} catch (error) {
console.log("Error", error);
}
},
{
autorun: false,
connection: redisConfig,
},
);
/** 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(),
};
export const deploymentWorker = !IS_CLOUD
? createDeploymentWorker()
: (noopWorker as unknown as Worker<DeploymentJob>);

View File

@@ -1,262 +0,0 @@
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,89 +3,32 @@ import {
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import { resolveBuildsConcurrency } from "./concurrency";
import { processDeploymentJob } from "./deployments-queue";
import { type InMemoryJob, InMemoryQueue } from "./in-memory-queue";
import type { DeploymentJob } from "./queue-types";
import type { Job } from "bullmq";
import { Queue } from "bullmq";
import { deploymentWorker } from "./deployments-queue";
import { redisConfig } from "./redis-connection";
/**
* 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([]),
/** 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),
close: () => Promise.resolve(),
on: () => {},
run: () => Promise.resolve(),
removeWaiting: () => 0,
clearWaiting: () => 0,
});
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();
const myQueue = !IS_CLOUD
? new Queue("deployments", { connection: redisConfig })
: (createNoopQueue() as unknown as Queue);
export const getJobsByApplicationId = async (applicationId: string) => {
const jobs = await myQueue.getJobs();
return jobs.filter(
(job) => (job.data as any)?.applicationId === applicationId,
);
return jobs.filter((job) => job?.data?.applicationId === applicationId);
};
export const getJobsByComposeId = async (composeId: string) => {
const jobs = await myQueue.getJobs();
return jobs.filter((job) => (job.data as any)?.composeId === composeId);
return jobs.filter((job) => job?.data?.composeId === composeId);
};
if (!IS_CLOUD) {
@@ -93,33 +36,44 @@ 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 removed = myQueue.removeWaiting(
(data) => (data as any)?.applicationId === applicationId,
);
if (removed > 0) {
console.log(
`Removed ${removed} waiting job(s) for application ${applicationId}`,
);
}
};
const jobs = await myQueue.getJobs(["waiting", "delayed"]);
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}`);
for (const job of jobs) {
if (job?.data?.applicationId === applicationId) {
await job.remove();
console.log(`Removed job ${job.id} for application ${applicationId}`);
}
}
};
export const cleanAllDeploymentQueue = async () => {
myQueue.clearWaiting();
deploymentWorker.cancelAllJobs("User requested cancellation");
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 { startDeploymentWorker } = await import("./queues/queueSetup");
await startDeploymentWorker();
const { deploymentWorker } = await import("./queues/deployments-queue");
await deploymentWorker.run();
}
} catch (e) {
console.error("Main Server Error", e);

View File

@@ -41,7 +41,6 @@ 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()
@@ -183,11 +182,6 @@ 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(100),
});
export const apiUpdateServerMonitoring = createSchema
.pick({
serverId: true,

View File

@@ -1,12 +1,5 @@
import { relations } from "drizzle-orm";
import {
boolean,
integer,
jsonb,
pgTable,
text,
timestamp,
} from "drizzle-orm/pg-core";
import { boolean, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
@@ -105,8 +98,6 @@ 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
@@ -170,11 +161,6 @@ 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(100).optional(),
});
export const apiUpdateWebServerBuildsConcurrency = z.object({
buildsConcurrency: z.number().int().min(1).max(100),
});
export const apiAssignDomain = z

View File

@@ -43,38 +43,6 @@ export const updateGitProvider = async (
.then((response) => response[0]);
};
// Returns true if the user can edit the git source configuration of an existing
// deploy that is connected to the given provider.
// Owner/admin: always yes.
// Member: only if they own the provider or it's shared with the org.
// Being in accessedGitProviders only grants permission to connect NEW deploys,
// not to modify the git config of an existing deploy owned by someone else.
export const canEditDeployGitSource = async (
gitProviderId: string,
session: { userId: string; activeOrganizationId: string },
): Promise<boolean> => {
const { userId, activeOrganizationId } = session;
const memberRecord = await db.query.member.findFirst({
where: and(
eq(member.userId, userId),
eq(member.organizationId, activeOrganizationId),
),
columns: { role: true },
});
if (memberRecord?.role === "owner") return true;
const provider = await db.query.gitProvider.findFirst({
where: eq(gitProvider.gitProviderId, gitProviderId),
columns: { userId: true, sharedWithOrganization: true },
});
if (!provider) return false;
return provider.userId === userId || provider.sharedWithOrganization;
};
export const getAccessibleGitProviderIds = async (session: {
userId: string;
activeOrganizationId: string;

View File

@@ -27,16 +27,6 @@ export function safeDockerLoginCommand(
return `printf %s ${escapedPassword} | docker login ${escapedRegistry} -u ${escapedUser} --password-stdin`;
}
function sanitizeRegistryError(
error: unknown,
password: string | null | undefined,
): string {
const message =
error instanceof Error ? error.message : "Error with registry login";
if (!password) return message;
return message.split(password).join("***");
}
export const createRegistry = async (
input: z.infer<typeof apiCreateRegistry>,
organizationId: string,
@@ -69,15 +59,10 @@ export const createRegistry = async (
input.username,
input.password,
);
try {
if (input.serverId && input.serverId !== "none") {
await execAsyncRemote(input.serverId, loginCommand);
} else if (newRegistry.registryType === "cloud") {
await execAsync(loginCommand);
}
} catch (error) {
const sanitized = sanitizeRegistryError(error, input.password);
throw new TRPCError({ code: "BAD_REQUEST", message: sanitized });
if (input.serverId && input.serverId !== "none") {
await execAsyncRemote(input.serverId, loginCommand);
} else if (newRegistry.registryType === "cloud") {
await execAsync(loginCommand);
}
return newRegistry;
@@ -144,24 +129,16 @@ export const updateRegistry = async (
});
}
try {
if (registryData?.serverId && registryData?.serverId !== "none") {
await execAsyncRemote(registryData.serverId, loginCommand);
} else if (response?.registryType === "cloud") {
await execAsync(loginCommand);
}
} catch (execError) {
throw new Error(sanitizeRegistryError(execError, response?.password));
if (registryData?.serverId && registryData?.serverId !== "none") {
await execAsyncRemote(registryData.serverId, loginCommand);
} else if (response?.registryType === "cloud") {
await execAsync(loginCommand);
}
return response;
} catch (error) {
const message =
error instanceof TRPCError
? error.message
: error instanceof Error
? error.message
: "Error updating this registry";
error instanceof Error ? error.message : "Error updating this registry";
throw new TRPCError({
code: "BAD_REQUEST",
message,

View File

@@ -1,6 +1,5 @@
import { createHmac } from "node:crypto";
import type { CreateServiceOptions } from "dockerode";
import { betterAuthSecret } from "../lib/auth-secret";
import { getRemoteDocker } from "../utils/servers/remote-docker";
export const FORWARD_AUTH_SERVICE_NAME = "dokploy-forward-auth";
@@ -38,12 +37,15 @@ export const forwardAuthCallbackUrl = (
): string => `${https ? "https" : "http"}://${authDomain}/oauth2/callback`;
export const deriveCookieSecret = (salt: string): string => {
// oauth2-proxy requires cookie_secret to be exactly 16, 24, or 32 bytes.
// Take the first 32 hex chars (= 16 bytes) to satisfy that constraint.
return createHmac("sha256", betterAuthSecret)
const rootSecret = process.env.BETTER_AUTH_SECRET;
if (!rootSecret) {
throw new Error(
"BETTER_AUTH_SECRET is required to derive the forward-auth cookie secret",
);
}
return createHmac("sha256", rootSecret)
.update(`forward-auth:${salt}`)
.digest("hex")
.slice(0, 32);
.digest("base64");
};
export const buildForwardAuthEnv = (

View File

@@ -44,15 +44,10 @@ export const getRailpackCommand = (application: ApplicationNested) => {
const secretsHash = calculateSecretsHash(envVariables);
const cacheKey = cleanCache ? nanoid(10) : undefined;
// 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)}`;
// Build command
const buildArgs = [
"buildx",
"build",
"--builder",
builderName,
...(cacheKey
? [
"--build-arg",
@@ -89,16 +84,17 @@ export const getRailpackCommand = (application: ApplicationNested) => {
const bashCommand = `
# Ensure we have a builder with containerd (isolated per build)
# Ensure we have a builder with containerd
export RAILPACK_VERSION=${application.railpackVersion}
bash -c "$(curl -fsSL https://railpack.com/install.sh)"
docker buildx create --name ${builderName} --driver docker-container || true
docker buildx create --use --name builder-containerd --driver docker-container || true
docker buildx use builder-containerd
echo "Preparing Railpack build plan..." ;
railpack ${prepareArgs.join(" ")} || {
railpack ${prepareArgs.join(" ")} || {
echo "❌ Railpack prepare failed" ;
docker buildx rm ${builderName} || true
docker buildx rm builder-containerd || true
exit 1;
}
echo "✅ Railpack prepare completed." ;
@@ -106,13 +102,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 ${builderName} || true
docker buildx rm builder-containerd || true
exit 1;
}
echo "✅ Railpack build completed." ;
docker buildx rm ${builderName} || true
docker buildx rm builder-containerd
`;
return bashCommand;

View File

@@ -1,7 +1,6 @@
import { findAllDeploymentsByApplicationId } from "@dokploy/server/services/deployment";
import {
findRegistryByIdWithCredentials,
safeDockerLoginCommand,
type Registry,
} from "@dokploy/server/services/registry";
import { createRollback } from "@dokploy/server/services/rollbacks";
@@ -118,14 +117,9 @@ const getRegistryCommands = (
imageName: string,
registryTag: string,
): string => {
const loginCmd = safeDockerLoginCommand(
registry.registryUrl,
registry.username,
registry.password,
);
return `
echo "📦 [Enabled Registry] Uploading image to '${registry.registryType}' | '${registryTag}'" ;
${loginCmd} || {
echo "${registry.password}" | docker login ${registry.registryUrl} -u '${registry.username}' --password-stdin || {
echo "❌ DockerHub Failed" ;
exit 1;
}

View File

@@ -1,4 +1,3 @@
import { safeDockerLoginCommand } from "@dokploy/server/services/registry";
import type { ApplicationNested } from "../builders";
export const buildRemoteDocker = async (application: ApplicationNested) => {
@@ -14,7 +13,7 @@ echo "Pulling ${dockerImage}";
if (username && password) {
command += `
if ! ${safeDockerLoginCommand(registryUrl || "", username, password)} 2>&1; then
if ! echo "${password}" | docker login --username "${username}" --password-stdin "${registryUrl || ""}" 2>&1; then
echo "❌ Login failed";
exit 1;
fi

View File

@@ -1,5 +1,5 @@
import { applications, compose, deployments } from "@dokploy/server/db/schema";
import { eq, inArray } from "drizzle-orm";
import { deployments } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm";
import { db } from "../../db/index";
export const initCancelDeployments = async () => {
@@ -14,36 +14,6 @@ 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);