mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-14 18:35:24 +02:00
Merge branch 'canary' into feat/application-icon-upload
# Conflicts: # apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx # apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx # apps/dokploy/server/api/routers/application.ts
This commit is contained in:
@@ -1 +0,0 @@
|
||||
20.16.0
|
||||
@@ -32,6 +32,8 @@ describe("Host rule format regression tests", () => {
|
||||
previewDeploymentId: "",
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
customEntrypoint: null,
|
||||
middlewares: null,
|
||||
};
|
||||
|
||||
describe("Host rule format validation", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ describe("createDomainLabels", () => {
|
||||
const baseDomain: Domain = {
|
||||
host: "example.com",
|
||||
port: 8080,
|
||||
customEntrypoint: null,
|
||||
https: false,
|
||||
uniqueConfigKey: 1,
|
||||
customCertResolver: null,
|
||||
@@ -21,6 +22,7 @@ describe("createDomainLabels", () => {
|
||||
previewDeploymentId: "",
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
};
|
||||
|
||||
it("should create basic labels for web entrypoint", async () => {
|
||||
@@ -171,12 +173,12 @@ describe("createDomainLabels", () => {
|
||||
"websecure",
|
||||
);
|
||||
|
||||
// Web entrypoint should have both middlewares with redirect first
|
||||
// Web entrypoint with HTTPS should only have redirect
|
||||
expect(webLabels).toContain(
|
||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,addprefix-test-app-1",
|
||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
||||
);
|
||||
|
||||
// Websecure should only have the addprefix middleware
|
||||
// Websecure should have the addprefix middleware
|
||||
expect(websecureLabels).toContain(
|
||||
"traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1",
|
||||
);
|
||||
@@ -208,9 +210,9 @@ describe("createDomainLabels", () => {
|
||||
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
||||
);
|
||||
|
||||
// Should have middlewares in correct order: redirect, stripprefix, addprefix
|
||||
// Web router with HTTPS should only have redirect
|
||||
expect(webLabels).toContain(
|
||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,stripprefix-test-app-1,addprefix-test-app-1",
|
||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -240,4 +242,259 @@ describe("createDomainLabels", () => {
|
||||
"traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("should add single custom middleware to router", async () => {
|
||||
const customMiddlewareDomain = {
|
||||
...baseDomain,
|
||||
middlewares: ["auth@file"],
|
||||
};
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
customMiddlewareDomain,
|
||||
"web",
|
||||
);
|
||||
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-web.middlewares=auth@file",
|
||||
);
|
||||
});
|
||||
|
||||
it("should add multiple custom middlewares to router", async () => {
|
||||
const customMiddlewareDomain = {
|
||||
...baseDomain,
|
||||
middlewares: ["auth@file", "rate-limit@file"],
|
||||
};
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
customMiddlewareDomain,
|
||||
"web",
|
||||
);
|
||||
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-web.middlewares=auth@file,rate-limit@file",
|
||||
);
|
||||
});
|
||||
|
||||
it("should only have redirect on web router when HTTPS is enabled with custom middlewares", async () => {
|
||||
const combinedDomain = {
|
||||
...baseDomain,
|
||||
https: true,
|
||||
middlewares: ["auth@file"],
|
||||
};
|
||||
const labels = await createDomainLabels(appName, combinedDomain, "web");
|
||||
|
||||
// Web router with HTTPS should only redirect, custom middlewares go on websecure
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
||||
);
|
||||
expect(labels).not.toContain("auth@file");
|
||||
});
|
||||
|
||||
it("should combine custom middlewares with stripPath middleware (no HTTPS)", async () => {
|
||||
const combinedDomain = {
|
||||
...baseDomain,
|
||||
path: "/api",
|
||||
stripPath: true,
|
||||
middlewares: ["auth@file"],
|
||||
};
|
||||
const labels = await createDomainLabels(appName, combinedDomain, "web");
|
||||
|
||||
// stripprefix should come before custom middleware
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-web.middlewares=stripprefix-test-app-1,auth@file",
|
||||
);
|
||||
});
|
||||
|
||||
it("should only have redirect on web router even with all built-in middlewares and custom middlewares", async () => {
|
||||
const fullDomain = {
|
||||
...baseDomain,
|
||||
https: true,
|
||||
path: "/api",
|
||||
stripPath: true,
|
||||
internalPath: "/hello",
|
||||
middlewares: ["auth@file", "rate-limit@file"],
|
||||
};
|
||||
const webLabels = await createDomainLabels(appName, fullDomain, "web");
|
||||
|
||||
// Web router with HTTPS should only redirect
|
||||
expect(webLabels).toContain(
|
||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
||||
);
|
||||
// Middleware definitions should still be present (Traefik needs them registered)
|
||||
expect(webLabels).toContain(
|
||||
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
|
||||
);
|
||||
expect(webLabels).toContain(
|
||||
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
||||
);
|
||||
// But they should NOT be attached to the router
|
||||
expect(webLabels).not.toContain("stripprefix-test-app-1,");
|
||||
expect(webLabels).not.toContain("auth@file");
|
||||
expect(webLabels).not.toContain("rate-limit@file");
|
||||
});
|
||||
|
||||
it("should include custom middlewares on websecure entrypoint", async () => {
|
||||
const customMiddlewareDomain = {
|
||||
...baseDomain,
|
||||
https: true,
|
||||
middlewares: ["auth@file"],
|
||||
};
|
||||
const websecureLabels = await createDomainLabels(
|
||||
appName,
|
||||
customMiddlewareDomain,
|
||||
"websecure",
|
||||
);
|
||||
|
||||
// Websecure should have custom middleware but not redirect-to-https
|
||||
expect(websecureLabels).toContain(
|
||||
"traefik.http.routers.test-app-1-websecure.middlewares=auth@file",
|
||||
);
|
||||
expect(websecureLabels).not.toContain("redirect-to-https");
|
||||
});
|
||||
|
||||
it("should NOT include custom middlewares on web router when HTTPS is enabled (only redirect)", async () => {
|
||||
const domain = {
|
||||
...baseDomain,
|
||||
https: true,
|
||||
middlewares: ["rate-limit@file", "auth@file"],
|
||||
};
|
||||
const webLabels = await createDomainLabels(appName, domain, "web");
|
||||
|
||||
// Web router with HTTPS should ONLY have redirect, not custom middlewares
|
||||
expect(webLabels).toContain(
|
||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
||||
);
|
||||
expect(webLabels).not.toContain("rate-limit@file");
|
||||
expect(webLabels).not.toContain("auth@file");
|
||||
});
|
||||
|
||||
it("should create basic labels for custom entrypoint", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
{ ...baseDomain, customEntrypoint: "custom" },
|
||||
"custom",
|
||||
);
|
||||
expect(labels).toEqual([
|
||||
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
|
||||
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
|
||||
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
|
||||
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should create https labels for custom entrypoint", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
{
|
||||
...baseDomain,
|
||||
https: true,
|
||||
customEntrypoint: "custom",
|
||||
certificateType: "letsencrypt",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
expect(labels).toEqual([
|
||||
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
|
||||
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
|
||||
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
|
||||
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
|
||||
"traefik.http.routers.test-app-1-custom.tls.certresolver=letsencrypt",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should add stripPath middleware for custom entrypoint", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
{
|
||||
...baseDomain,
|
||||
customEntrypoint: "custom",
|
||||
path: "/api",
|
||||
stripPath: true,
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(labels).toContain(
|
||||
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
|
||||
);
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("should add internalPath middleware for custom entrypoint", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
{
|
||||
...baseDomain,
|
||||
customEntrypoint: "custom",
|
||||
internalPath: "/hello",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(labels).toContain(
|
||||
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
||||
);
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-custom.middlewares=addprefix-test-app-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("should add path prefix in rule for custom entrypoint", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
{
|
||||
...baseDomain,
|
||||
customEntrypoint: "custom",
|
||||
path: "/api",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`) && PathPrefix(`/api`)",
|
||||
);
|
||||
});
|
||||
|
||||
it("should combine all middlewares for custom entrypoint", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
{
|
||||
...baseDomain,
|
||||
customEntrypoint: "custom",
|
||||
path: "/api",
|
||||
stripPath: true,
|
||||
internalPath: "/hello",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(labels).toContain(
|
||||
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
|
||||
);
|
||||
expect(labels).toContain(
|
||||
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
||||
);
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not add redirect-to-https for custom entrypoint even with https", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
{
|
||||
...baseDomain,
|
||||
customEntrypoint: "custom",
|
||||
https: true,
|
||||
certificateType: "letsencrypt",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
const middlewareLabel = labels.find((l) => l.includes(".middlewares="));
|
||||
// Should not contain redirect-to-https since there's only one router
|
||||
expect(middlewareLabel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,21 +4,30 @@ import { describe, expect, it } from "vitest";
|
||||
describe("addDokployNetworkToService", () => {
|
||||
it("should add network to an empty array", () => {
|
||||
const result = addDokployNetworkToService([]);
|
||||
expect(result).toEqual(["dokploy-network"]);
|
||||
expect(result).toEqual(["dokploy-network", "default"]);
|
||||
});
|
||||
|
||||
it("should not add duplicate network to an array", () => {
|
||||
const result = addDokployNetworkToService(["dokploy-network"]);
|
||||
expect(result).toEqual(["dokploy-network"]);
|
||||
expect(result).toEqual(["dokploy-network", "default"]);
|
||||
});
|
||||
|
||||
it("should add network to an existing array with other networks", () => {
|
||||
const result = addDokployNetworkToService(["other-network"]);
|
||||
expect(result).toEqual(["other-network", "dokploy-network"]);
|
||||
expect(result).toEqual(["other-network", "dokploy-network", "default"]);
|
||||
});
|
||||
|
||||
it("should add network to an object if networks is an object", () => {
|
||||
const result = addDokployNetworkToService({ "other-network": {} });
|
||||
expect(result).toEqual({ "other-network": {}, "dokploy-network": {} });
|
||||
expect(result).toEqual({
|
||||
"other-network": {},
|
||||
"dokploy-network": {},
|
||||
default: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("should not duplicate default network when already present", () => {
|
||||
const result = addDokployNetworkToService(["default", "dokploy-network"]);
|
||||
expect(result).toEqual(["default", "dokploy-network"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,7 +292,7 @@ networks:
|
||||
dokploy-network:
|
||||
`;
|
||||
|
||||
test("It shoudn't add suffix to dokploy-network", () => {
|
||||
test("It shouldn't add suffix to dokploy-network", () => {
|
||||
const composeData = parse(composeFile7) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -195,7 +195,7 @@ services:
|
||||
- dokploy-network
|
||||
`;
|
||||
|
||||
test("It shoudn't add suffix to dokploy-network in services", () => {
|
||||
test("It shouldn't add suffix to dokploy-network in services", () => {
|
||||
const composeData = parse(composeFile7) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
@@ -241,10 +241,10 @@ services:
|
||||
dokploy-network:
|
||||
aliases:
|
||||
- apid
|
||||
|
||||
|
||||
`;
|
||||
|
||||
test("It shoudn't add suffix to dokploy-network in services multiples cases", () => {
|
||||
test("It shouldn't add suffix to dokploy-network in services multiples cases", () => {
|
||||
const composeData = parse(composeFile8) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -14,13 +14,18 @@ vi.mock("@dokploy/server/db", () => {
|
||||
set: vi.fn(() => chain),
|
||||
where: vi.fn(() => chain),
|
||||
returning: vi.fn().mockResolvedValue([{}] as any),
|
||||
from: vi.fn(() => chain),
|
||||
innerJoin: vi.fn(() => chain),
|
||||
then: (resolve: (v: any) => void) => {
|
||||
resolve([]);
|
||||
},
|
||||
} as any;
|
||||
return chain;
|
||||
};
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
select: vi.fn(() => createChainableMock()),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(() => createChainableMock()),
|
||||
delete: vi.fn(),
|
||||
@@ -28,6 +33,12 @@ vi.mock("@dokploy/server/db", () => {
|
||||
applications: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
patch: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
member: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,13 +15,18 @@ vi.mock("@dokploy/server/db", () => {
|
||||
set: vi.fn(() => chain),
|
||||
where: vi.fn(() => chain),
|
||||
returning: vi.fn().mockResolvedValue([{}]),
|
||||
from: vi.fn(() => chain),
|
||||
innerJoin: vi.fn(() => chain),
|
||||
then: (resolve: (v: any) => void) => {
|
||||
resolve([]);
|
||||
},
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
select: vi.fn(() => createChainableMock()),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(() => createChainableMock()),
|
||||
delete: vi.fn(),
|
||||
@@ -29,6 +34,12 @@ vi.mock("@dokploy/server/db", () => {
|
||||
applications: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
patch: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
member: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -83,6 +83,14 @@ describe("GitHub Webhook Skip CI", () => {
|
||||
{ commits: [{ message: "[skip ci] test" }] },
|
||||
),
|
||||
).toBe("[skip ci] test");
|
||||
|
||||
// Soft Serve
|
||||
expect(
|
||||
extractCommitMessage(
|
||||
{ "x-softserve-event": "push" },
|
||||
{ commits: [{ message: "[skip ci] test" }] },
|
||||
),
|
||||
).toBe("[skip ci] test");
|
||||
});
|
||||
|
||||
it("should handle missing commit message", () => {
|
||||
@@ -99,6 +107,9 @@ describe("GitHub Webhook Skip CI", () => {
|
||||
expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe(
|
||||
"NEW COMMIT",
|
||||
);
|
||||
expect(extractCommitMessage({ "x-softserve-event": "push" }, {})).toBe(
|
||||
"NEW COMMIT",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
49
apps/dokploy/__test__/deploy/soft-serve.test.ts
Normal file
49
apps/dokploy/__test__/deploy/soft-serve.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractBranchName,
|
||||
extractCommitMessage,
|
||||
extractHash,
|
||||
getProviderByHeader,
|
||||
} from "@/pages/api/deploy/[refreshToken]";
|
||||
|
||||
describe("Soft Serve Webhook", () => {
|
||||
const mockSoftServeHeaders = {
|
||||
"x-softserve-event": "push",
|
||||
};
|
||||
|
||||
const createMockBody = (message: string, hash: string, branch: string) => ({
|
||||
event: "push",
|
||||
ref: `refs/heads/${branch}`,
|
||||
after: hash,
|
||||
commits: [{ message: message }],
|
||||
});
|
||||
const message: string = "feat: add new feature";
|
||||
const hash: string = "3c91c24ef9560bddc695bce138bf8a7094ec3df5";
|
||||
const branch: string = "feat/add-new";
|
||||
const goodWebhook = createMockBody(message, hash, branch);
|
||||
|
||||
it("should properly extract the provider name", () => {
|
||||
expect(getProviderByHeader(mockSoftServeHeaders)).toBe("soft-serve");
|
||||
});
|
||||
|
||||
it("should properly extract the commit message", () => {
|
||||
expect(extractCommitMessage(mockSoftServeHeaders, goodWebhook)).toBe(
|
||||
message,
|
||||
);
|
||||
});
|
||||
|
||||
it("should properly extract hash", () => {
|
||||
expect(extractHash(mockSoftServeHeaders, goodWebhook)).toBe(hash);
|
||||
});
|
||||
|
||||
it("should properly extract branch name", () => {
|
||||
expect(extractBranchName(mockSoftServeHeaders, goodWebhook)).toBe(branch);
|
||||
});
|
||||
|
||||
it("should gracefully handle invalid webhook", () => {
|
||||
expect(getProviderByHeader({})).toBeNull();
|
||||
expect(extractCommitMessage(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
|
||||
expect(extractHash(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
|
||||
expect(extractBranchName(mockSoftServeHeaders, {})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { paths } from "@dokploy/server/constants";
|
||||
import AdmZip from "adm-zip";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const OUTPUT_BASE = "./__test__/drop/zips/output";
|
||||
const { APPLICATIONS_PATH } = paths();
|
||||
vi.mock("@dokploy/server/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
@@ -13,7 +14,10 @@ vi.mock("@dokploy/server/constants", async (importOriginal) => {
|
||||
// @ts-ignore
|
||||
...actual,
|
||||
paths: () => ({
|
||||
APPLICATIONS_PATH: "./__test__/drop/zips/output",
|
||||
// @ts-ignore
|
||||
...actual.paths(),
|
||||
BASE_PATH: OUTPUT_BASE,
|
||||
APPLICATIONS_PATH: OUTPUT_BASE,
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -148,8 +152,179 @@ const baseApp: ApplicationNested = {
|
||||
dockerContextPath: null,
|
||||
rollbackActive: false,
|
||||
stopGracePeriodSwarm: null,
|
||||
ulimitsSwarm: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* GHSA-66v7-g3fh-47h3: Remote Code Execution through Path Traversal.
|
||||
* Validates the exact PoC: ZIP with path traversal entry ../../../../../etc/cron.d/malicious-cron
|
||||
* plus cover files (package.json, index.js). unzipDrop must reject and never write outside output.
|
||||
*/
|
||||
describe("GHSA-66v7-g3fh-47h3 path traversal RCE", () => {
|
||||
beforeAll(async () => {
|
||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||
});
|
||||
afterAll(async () => {
|
||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rejects PoC ZIP: traversal ../../../../../etc/cron.d/malicious-cron + package.json + index.js", async () => {
|
||||
baseApp.appName = "ghsa-rce";
|
||||
// PoC payload: same entry name as advisory (Python zipfile keeps it; AdmZip normalizes on add → use placeholder + replace)
|
||||
const traversalEntry = "../../../../../etc/cron.d/malicious-cron";
|
||||
const cronPayload = "* * * * * root id\n";
|
||||
const placeholder = "x".repeat(traversalEntry.length);
|
||||
const zip = new AdmZip();
|
||||
zip.addFile(
|
||||
"package.json",
|
||||
Buffer.from('{"name": "app", "version": "1.0.0"}'),
|
||||
);
|
||||
zip.addFile("index.js", Buffer.from('console.log("Application");'));
|
||||
zip.addFile(placeholder, Buffer.from(cronPayload));
|
||||
let buf = Buffer.from(zip.toBuffer());
|
||||
buf = Buffer.from(
|
||||
buf.toString("binary").split(placeholder).join(traversalEntry),
|
||||
"binary",
|
||||
);
|
||||
const file = new File([buf as unknown as ArrayBuffer], "exploit.zip");
|
||||
await expect(unzipDrop(file, baseApp)).rejects.toThrow(
|
||||
/Path traversal detected.*resolved path escapes output directory/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("security: existing symlink escape", () => {
|
||||
beforeAll(async () => {
|
||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should NOT write outside base when directory is a symlink", async () => {
|
||||
const appName = "symlink-existing";
|
||||
const output = path.join(APPLICATIONS_PATH, appName, "code");
|
||||
await fs.mkdir(output, { recursive: true });
|
||||
|
||||
// outside target (attacker wants to write here)
|
||||
const outside = path.join(APPLICATIONS_PATH, "..", "outside");
|
||||
await fs.mkdir(outside, { recursive: true });
|
||||
|
||||
// attacker-controlled symlink inside project
|
||||
await fs.symlink(outside, path.join(output, "logs"));
|
||||
|
||||
// zip looks totally harmless
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("logs/pwned.txt", Buffer.from("owned"));
|
||||
|
||||
const file = new File([zip.toBuffer() as any], "exploit.zip");
|
||||
|
||||
await unzipDrop(file, { ...baseApp, appName });
|
||||
|
||||
// if vulnerable -> file exists outside sandbox
|
||||
const escaped = await fs
|
||||
.readFile(path.join(outside, "pwned.txt"), "utf8")
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
expect(escaped).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("security: zip symlink entry blocked", () => {
|
||||
beforeAll(async () => {
|
||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rejects zip containing real symlink entry", async () => {
|
||||
const appName = "zip-symlink";
|
||||
|
||||
const zipBuffer = await fs.readFile(
|
||||
path.join(__dirname, "./zips/payload/symlink-entry.zip"),
|
||||
);
|
||||
|
||||
const file = new File([zipBuffer as any], "exploit.zip");
|
||||
|
||||
await expect(unzipDrop(file, { ...baseApp, appName })).rejects.toThrow(
|
||||
/Dangerous node entries are not allowed/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unzipDrop path under output (no traversal)", () => {
|
||||
beforeAll(async () => {
|
||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||
});
|
||||
afterAll(async () => {
|
||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("allows entry etc/cron.d/malicious-cron when under output (no path traversal)", async () => {
|
||||
baseApp.appName = "cron-under-output";
|
||||
const zip = new AdmZip();
|
||||
zip.addFile(
|
||||
"etc/cron.d/malicious-cron",
|
||||
Buffer.from("* * * * * root id\n"),
|
||||
);
|
||||
zip.addFile("package.json", Buffer.from('{"name":"app"}'));
|
||||
const file = new File(
|
||||
[zip.toBuffer() as unknown as ArrayBuffer],
|
||||
"app.zip",
|
||||
);
|
||||
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
||||
await unzipDrop(file, baseApp);
|
||||
const content = await fs.readFile(
|
||||
path.join(outputPath, "etc/cron.d/malicious-cron"),
|
||||
"utf8",
|
||||
);
|
||||
expect(content).toBe("* * * * * root id\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("security: traversal inside BASE_PATH (sandbox escape)", () => {
|
||||
beforeAll(async () => {
|
||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should NOT allow writing outside application directory but inside BASE_PATH", async () => {
|
||||
const appName = "sandbox-escape";
|
||||
|
||||
const base = APPLICATIONS_PATH.replace("/applications", "");
|
||||
const output = path.join(APPLICATIONS_PATH, appName, "code");
|
||||
|
||||
await fs.mkdir(output, { recursive: true });
|
||||
|
||||
// attacker writes into traefik config inside base
|
||||
const zip = new AdmZip();
|
||||
zip.addFile(
|
||||
"../../../traefik/dynamic/evil.yml",
|
||||
Buffer.from("pwned: true"),
|
||||
);
|
||||
|
||||
const file = new File([zip.toBuffer() as any], "exploit.zip");
|
||||
|
||||
await unzipDrop(file, { ...baseApp, appName });
|
||||
|
||||
const escapedPath = path.join(base, "traefik/dynamic/evil.yml");
|
||||
|
||||
const exists = await fs
|
||||
.readFile(escapedPath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
expect(exists).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unzipDrop using real zip files", () => {
|
||||
// const { APPLICATIONS_PATH } = paths();
|
||||
beforeAll(async () => {
|
||||
@@ -166,14 +341,12 @@ describe("unzipDrop using real zip files", () => {
|
||||
try {
|
||||
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
||||
const zip = new AdmZip("./__test__/drop/zips/single-file.zip");
|
||||
console.log(`Output Path: ${outputPath}`);
|
||||
const zipBuffer = zip.toBuffer() as Buffer<ArrayBuffer>;
|
||||
const file = new File([zipBuffer], "single.zip");
|
||||
await unzipDrop(file, baseApp);
|
||||
const files = await fs.readdir(outputPath, { withFileTypes: true });
|
||||
expect(files.some((f) => f.name === "test.txt")).toBe(true);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
} finally {
|
||||
}
|
||||
});
|
||||
|
||||
1
apps/dokploy/__test__/drop/zips/payload/link
Symbolic link
1
apps/dokploy/__test__/drop/zips/payload/link
Symbolic link
@@ -0,0 +1 @@
|
||||
/etc/passwd
|
||||
BIN
apps/dokploy/__test__/drop/zips/payload/symlink-entry.zip
Normal file
BIN
apps/dokploy/__test__/drop/zips/payload/symlink-entry.zip
Normal file
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
import { getEnviromentVariablesObject } from "@dokploy/server/index";
|
||||
import { getEnvironmentVariablesObject } from "@dokploy/server/index";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const projectEnv = `
|
||||
@@ -15,7 +15,7 @@ DATABASE_NAME=dev_database
|
||||
SECRET_KEY=env-secret-123
|
||||
`;
|
||||
|
||||
describe("getEnviromentVariablesObject with environment variables (Stack compose)", () => {
|
||||
describe("getEnvironmentVariablesObject with environment variables (Stack compose)", () => {
|
||||
it("resolves environment variables correctly for Stack compose", () => {
|
||||
const serviceEnv = `
|
||||
FOO=\${{environment.NODE_ENV}}
|
||||
@@ -23,7 +23,7 @@ BAR=\${{environment.API_URL}}
|
||||
BAZ=test
|
||||
`;
|
||||
|
||||
const result = getEnviromentVariablesObject(
|
||||
const result = getEnvironmentVariablesObject(
|
||||
serviceEnv,
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
@@ -45,7 +45,7 @@ DATABASE_URL=\${{project.DATABASE_URL}}
|
||||
SERVICE_PORT=4000
|
||||
`;
|
||||
|
||||
const result = getEnviromentVariablesObject(
|
||||
const result = getEnvironmentVariablesObject(
|
||||
serviceEnv,
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
@@ -72,7 +72,7 @@ PASSWORD=secret123
|
||||
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
|
||||
`;
|
||||
|
||||
const result = getEnviromentVariablesObject(serviceEnv, "", multiRefEnv);
|
||||
const result = getEnvironmentVariablesObject(serviceEnv, "", multiRefEnv);
|
||||
|
||||
expect(result).toEqual({
|
||||
DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
|
||||
@@ -85,7 +85,7 @@ UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}}
|
||||
`;
|
||||
|
||||
expect(() =>
|
||||
getEnviromentVariablesObject(serviceWithUndefined, "", environmentEnv),
|
||||
getEnvironmentVariablesObject(serviceWithUndefined, "", environmentEnv),
|
||||
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ NODE_ENV=production
|
||||
API_URL=\${{environment.API_URL}}
|
||||
`;
|
||||
|
||||
const result = getEnviromentVariablesObject(
|
||||
const result = getEnvironmentVariablesObject(
|
||||
serviceOverrideEnv,
|
||||
"",
|
||||
environmentEnv,
|
||||
@@ -115,7 +115,7 @@ SERVICE_NAME=my-service
|
||||
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
|
||||
`;
|
||||
|
||||
const result = getEnviromentVariablesObject(
|
||||
const result = getEnvironmentVariablesObject(
|
||||
complexServiceEnv,
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
@@ -150,7 +150,7 @@ ENV_VAR=\${{environment.API_URL}}
|
||||
DB_NAME=\${{environment.DATABASE_NAME}}
|
||||
`;
|
||||
|
||||
const result = getEnviromentVariablesObject(
|
||||
const result = getEnvironmentVariablesObject(
|
||||
serviceWithConflicts,
|
||||
conflictingProjectEnv,
|
||||
conflictingEnvironmentEnv,
|
||||
@@ -170,7 +170,7 @@ SERVICE_VAR=test
|
||||
PROJECT_VAR=\${{project.ENVIRONMENT}}
|
||||
`;
|
||||
|
||||
const result = getEnviromentVariablesObject(
|
||||
const result = getEnvironmentVariablesObject(
|
||||
serviceWithEmpty,
|
||||
projectEnv,
|
||||
"",
|
||||
|
||||
144
apps/dokploy/__test__/permissions/check-permission.test.ts
Normal file
144
apps/dokploy/__test__/permissions/check-permission.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockMemberData = (
|
||||
role: string,
|
||||
overrides: Record<string, boolean> = {},
|
||||
) => ({
|
||||
id: "member-1",
|
||||
role,
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
accessedProjects: [] as string[],
|
||||
accessedServices: [] as string[],
|
||||
accessedEnvironments: [] as string[],
|
||||
canCreateProjects: overrides.canCreateProjects ?? false,
|
||||
canDeleteProjects: overrides.canDeleteProjects ?? false,
|
||||
canCreateServices: overrides.canCreateServices ?? false,
|
||||
canDeleteServices: overrides.canDeleteServices ?? false,
|
||||
canCreateEnvironments: overrides.canCreateEnvironments ?? false,
|
||||
canDeleteEnvironments: overrides.canDeleteEnvironments ?? false,
|
||||
canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false,
|
||||
canAccessToDocker: overrides.canAccessToDocker ?? false,
|
||||
canAccessToAPI: overrides.canAccessToAPI ?? false,
|
||||
canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false,
|
||||
canAccessToGitProviders: overrides.canAccessToGitProviders ?? false,
|
||||
user: { id: "user-1", email: "test@test.com" },
|
||||
});
|
||||
|
||||
let memberToReturn: ReturnType<typeof mockMemberData> =
|
||||
mockMemberData("member");
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
member: {
|
||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
organizationRole: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: vi.fn(() => Promise.resolve(false)),
|
||||
}));
|
||||
|
||||
const { checkPermission } = await import("@dokploy/server/services/permission");
|
||||
|
||||
const ctx = {
|
||||
user: { id: "user-1" },
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("static roles bypass enterprise resources", () => {
|
||||
it("owner bypasses deployment.read", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
await expect(
|
||||
checkPermission(ctx, { deployment: ["read"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("admin bypasses backup.create", async () => {
|
||||
memberToReturn = mockMemberData("admin");
|
||||
await expect(
|
||||
checkPermission(ctx, { backup: ["create"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member bypasses schedule.delete", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { schedule: ["delete"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member bypasses multiple enterprise permissions at once", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, {
|
||||
deployment: ["read"],
|
||||
backup: ["create"],
|
||||
domain: ["delete"],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("static roles validate free-tier resources", () => {
|
||||
it("owner passes project.create", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
await expect(
|
||||
checkPermission(ctx, { project: ["create"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member fails project.create (no legacy override)", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { project: ["create"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member passes service.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { service: ["read"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member fails service.create", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { service: ["create"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy boolean overrides for member", () => {
|
||||
it("member passes project.create with canCreateProjects=true", async () => {
|
||||
memberToReturn = mockMemberData("member", { canCreateProjects: true });
|
||||
await expect(
|
||||
checkPermission(ctx, { project: ["create"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member passes docker.read with canAccessToDocker=true", async () => {
|
||||
memberToReturn = mockMemberData("member", { canAccessToDocker: true });
|
||||
await expect(
|
||||
checkPermission(ctx, { docker: ["read"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member fails docker.read with canAccessToDocker=false", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(checkPermission(ctx, { docker: ["read"] })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
enterpriseOnlyResources,
|
||||
statements,
|
||||
} from "@dokploy/server/lib/access-control";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const FREE_TIER_RESOURCES = [
|
||||
"organization",
|
||||
"member",
|
||||
"invitation",
|
||||
"team",
|
||||
"ac",
|
||||
"project",
|
||||
"service",
|
||||
"environment",
|
||||
"docker",
|
||||
"sshKeys",
|
||||
"gitProviders",
|
||||
"traefikFiles",
|
||||
"api",
|
||||
];
|
||||
|
||||
const ENTERPRISE_RESOURCES = [
|
||||
"volume",
|
||||
"deployment",
|
||||
"envVars",
|
||||
"projectEnvVars",
|
||||
"environmentEnvVars",
|
||||
"server",
|
||||
"registry",
|
||||
"certificate",
|
||||
"backup",
|
||||
"volumeBackup",
|
||||
"schedule",
|
||||
"domain",
|
||||
"destination",
|
||||
"notification",
|
||||
"tag",
|
||||
"logs",
|
||||
"monitoring",
|
||||
"auditLog",
|
||||
];
|
||||
|
||||
describe("enterpriseOnlyResources set", () => {
|
||||
it("contains all enterprise resources", () => {
|
||||
for (const resource of ENTERPRISE_RESOURCES) {
|
||||
expect(enterpriseOnlyResources.has(resource)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT contain free-tier resources", () => {
|
||||
for (const resource of FREE_TIER_RESOURCES) {
|
||||
expect(enterpriseOnlyResources.has(resource)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("every resource in statements is either free or enterprise", () => {
|
||||
const allResources = Object.keys(statements);
|
||||
for (const resource of allResources) {
|
||||
const isFree = FREE_TIER_RESOURCES.includes(resource);
|
||||
const isEnterprise = enterpriseOnlyResources.has(resource);
|
||||
expect(isFree || isEnterprise).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("free and enterprise sets don't overlap", () => {
|
||||
for (const resource of FREE_TIER_RESOURCES) {
|
||||
expect(enterpriseOnlyResources.has(resource)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("all statement resources are accounted for", () => {
|
||||
const allResources = Object.keys(statements);
|
||||
const categorized = [...FREE_TIER_RESOURCES, ...ENTERPRISE_RESOURCES];
|
||||
for (const resource of allResources) {
|
||||
expect(categorized).toContain(resource);
|
||||
}
|
||||
});
|
||||
});
|
||||
161
apps/dokploy/__test__/permissions/resolve-permissions.test.ts
Normal file
161
apps/dokploy/__test__/permissions/resolve-permissions.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockMemberData = (
|
||||
role: string,
|
||||
overrides: Record<string, boolean> = {},
|
||||
) => ({
|
||||
id: "member-1",
|
||||
role,
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
accessedProjects: [] as string[],
|
||||
accessedServices: [] as string[],
|
||||
accessedEnvironments: [] as string[],
|
||||
canCreateProjects: overrides.canCreateProjects ?? false,
|
||||
canDeleteProjects: overrides.canDeleteProjects ?? false,
|
||||
canCreateServices: overrides.canCreateServices ?? false,
|
||||
canDeleteServices: overrides.canDeleteServices ?? false,
|
||||
canCreateEnvironments: overrides.canCreateEnvironments ?? false,
|
||||
canDeleteEnvironments: overrides.canDeleteEnvironments ?? false,
|
||||
canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false,
|
||||
canAccessToDocker: overrides.canAccessToDocker ?? false,
|
||||
canAccessToAPI: overrides.canAccessToAPI ?? false,
|
||||
canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false,
|
||||
canAccessToGitProviders: overrides.canAccessToGitProviders ?? false,
|
||||
user: { id: "user-1", email: "test@test.com" },
|
||||
});
|
||||
|
||||
let memberToReturn: ReturnType<typeof mockMemberData> =
|
||||
mockMemberData("member");
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
member: {
|
||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
organizationRole: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: vi.fn(() => Promise.resolve(false)),
|
||||
}));
|
||||
|
||||
const { resolvePermissions } = await import(
|
||||
"@dokploy/server/services/permission"
|
||||
);
|
||||
const { enterpriseOnlyResources, statements } = await import(
|
||||
"@dokploy/server/lib/access-control"
|
||||
);
|
||||
|
||||
const ctx = {
|
||||
user: { id: "user-1" },
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("enterprise resources for static roles", () => {
|
||||
it("owner gets true for all enterprise resources", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
|
||||
for (const resource of enterpriseOnlyResources) {
|
||||
const actions = statements[resource as keyof typeof statements];
|
||||
for (const action of actions) {
|
||||
expect((perms as any)[resource][action]).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("admin gets true for all enterprise resources", async () => {
|
||||
memberToReturn = mockMemberData("admin");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
|
||||
for (const resource of enterpriseOnlyResources) {
|
||||
const actions = statements[resource as keyof typeof statements];
|
||||
for (const action of actions) {
|
||||
expect((perms as any)[resource][action]).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("member gets true for service-level enterprise resources", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
|
||||
expect(perms.deployment.read).toBe(true);
|
||||
expect(perms.deployment.create).toBe(true);
|
||||
expect(perms.domain.read).toBe(true);
|
||||
expect(perms.backup.read).toBe(true);
|
||||
expect(perms.logs.read).toBe(true);
|
||||
expect(perms.monitoring.read).toBe(true);
|
||||
});
|
||||
|
||||
it("member gets false for org-level enterprise resources", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
|
||||
expect(perms.server.read).toBe(false);
|
||||
expect(perms.registry.read).toBe(false);
|
||||
expect(perms.certificate.read).toBe(false);
|
||||
expect(perms.destination.read).toBe(false);
|
||||
expect(perms.notification.read).toBe(false);
|
||||
expect(perms.auditLog.read).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("free-tier resources for member", () => {
|
||||
it("member gets service.read=true", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.service.read).toBe(true);
|
||||
});
|
||||
|
||||
it("member gets project.create=false without legacy override", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.project.create).toBe(false);
|
||||
});
|
||||
|
||||
it("member gets project.create=true with canCreateProjects", async () => {
|
||||
memberToReturn = mockMemberData("member", { canCreateProjects: true });
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.project.create).toBe(true);
|
||||
});
|
||||
|
||||
it("member gets docker.read=false without legacy override", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.docker.read).toBe(false);
|
||||
});
|
||||
|
||||
it("member gets docker.read=true with canAccessToDocker", async () => {
|
||||
memberToReturn = mockMemberData("member", { canAccessToDocker: true });
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.docker.read).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("free-tier resources for owner", () => {
|
||||
it("owner gets all free-tier permissions as true", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.project.create).toBe(true);
|
||||
expect(perms.project.delete).toBe(true);
|
||||
expect(perms.service.create).toBe(true);
|
||||
expect(perms.service.read).toBe(true);
|
||||
expect(perms.service.delete).toBe(true);
|
||||
expect(perms.docker.read).toBe(true);
|
||||
expect(perms.traefikFiles.read).toBe(true);
|
||||
expect(perms.traefikFiles.write).toBe(true);
|
||||
});
|
||||
});
|
||||
132
apps/dokploy/__test__/permissions/service-access.test.ts
Normal file
132
apps/dokploy/__test__/permissions/service-access.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockMemberData = (
|
||||
role: string,
|
||||
accessedServices: string[] = [],
|
||||
accessedProjects: string[] = [],
|
||||
) => ({
|
||||
id: "member-1",
|
||||
role,
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
accessedProjects,
|
||||
accessedServices,
|
||||
accessedEnvironments: [] as string[],
|
||||
canCreateProjects: false,
|
||||
canDeleteProjects: false,
|
||||
canCreateServices: false,
|
||||
canDeleteServices: false,
|
||||
canCreateEnvironments: false,
|
||||
canDeleteEnvironments: false,
|
||||
canAccessToTraefikFiles: false,
|
||||
canAccessToDocker: false,
|
||||
canAccessToAPI: false,
|
||||
canAccessToSSHKeys: false,
|
||||
canAccessToGitProviders: false,
|
||||
user: { id: "user-1", email: "test@test.com" },
|
||||
});
|
||||
|
||||
let memberToReturn: ReturnType<typeof mockMemberData> =
|
||||
mockMemberData("member");
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
member: {
|
||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
organizationRole: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: vi.fn(() => Promise.resolve(false)),
|
||||
}));
|
||||
|
||||
const { checkServicePermissionAndAccess, checkServiceAccess } = await import(
|
||||
"@dokploy/server/services/permission"
|
||||
);
|
||||
|
||||
const ctx = {
|
||||
user: { id: "user-1" },
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("checkServicePermissionAndAccess", () => {
|
||||
it("owner bypasses accessedServices check", async () => {
|
||||
memberToReturn = mockMemberData("owner", []);
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||
deployment: ["read"],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("admin bypasses accessedServices check", async () => {
|
||||
memberToReturn = mockMemberData("admin", []);
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||
backup: ["create"],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member with access to service passes", async () => {
|
||||
memberToReturn = mockMemberData("member", ["service-123"]);
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||
deployment: ["read"],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member WITHOUT access to service fails", async () => {
|
||||
memberToReturn = mockMemberData("member", ["other-service"]);
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||
deployment: ["read"],
|
||||
}),
|
||||
).rejects.toThrow("You don't have access to this service");
|
||||
});
|
||||
|
||||
it("member with empty accessedServices fails", async () => {
|
||||
memberToReturn = mockMemberData("member", []);
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||
domain: ["delete"],
|
||||
}),
|
||||
).rejects.toThrow("You don't have access to this service");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkServiceAccess", () => {
|
||||
it("member with service access passes read check", async () => {
|
||||
memberToReturn = mockMemberData("member", ["app-1"]);
|
||||
await expect(
|
||||
checkServiceAccess(ctx, "app-1", "read"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member without service access fails read check", async () => {
|
||||
memberToReturn = mockMemberData("member", []);
|
||||
await expect(checkServiceAccess(ctx, "app-1", "read")).rejects.toThrow(
|
||||
"You don't have access to this service",
|
||||
);
|
||||
});
|
||||
|
||||
it("owner bypasses all access checks", async () => {
|
||||
memberToReturn = mockMemberData("owner", [], []);
|
||||
await expect(
|
||||
checkServiceAccess(ctx, "project-1", "create"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ type MockCreateServiceOptions = {
|
||||
TaskTemplate?: {
|
||||
ContainerSpec?: {
|
||||
StopGracePeriod?: number;
|
||||
Ulimits?: Array<{ Name: string; Soft: number; Hard: number }>;
|
||||
};
|
||||
};
|
||||
[key: string]: unknown;
|
||||
@@ -57,6 +58,7 @@ const createApplication = (
|
||||
},
|
||||
replicas: 1,
|
||||
stopGracePeriodSwarm: 0n,
|
||||
ulimitsSwarm: null,
|
||||
serverId: "server-id",
|
||||
...overrides,
|
||||
}) as unknown as ApplicationNested;
|
||||
@@ -110,4 +112,50 @@ describe("mechanizeDockerContainer", () => {
|
||||
"StopGracePeriod",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes ulimits to ContainerSpec when ulimitsSwarm is defined", async () => {
|
||||
const ulimits = [
|
||||
{ Name: "nofile", Soft: 10000, Hard: 20000 },
|
||||
{ Name: "nproc", Soft: 4096, Hard: 8192 },
|
||||
];
|
||||
const application = createApplication({ ulimitsSwarm: ulimits });
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.TaskTemplate?.ContainerSpec?.Ulimits).toEqual(ulimits);
|
||||
});
|
||||
|
||||
it("omits Ulimits when ulimitsSwarm is null", async () => {
|
||||
const application = createApplication({ ulimitsSwarm: null });
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits");
|
||||
});
|
||||
|
||||
it("omits Ulimits when ulimitsSwarm is an empty array", async () => {
|
||||
const application = createApplication({ ulimitsSwarm: [] });
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,11 @@ vi.mock("@dokploy/server/db", () => {
|
||||
chain.where = () => chain;
|
||||
chain.values = () => chain;
|
||||
chain.returning = () => Promise.resolve([{}]);
|
||||
chain.then = undefined;
|
||||
chain.from = () => chain;
|
||||
chain.innerJoin = () => chain;
|
||||
chain.then = (resolve: (value: unknown) => void) => {
|
||||
resolve([]);
|
||||
};
|
||||
|
||||
const tableMock = {
|
||||
findFirst: vi.fn(() => Promise.resolve(undefined)),
|
||||
@@ -21,7 +25,6 @@ vi.mock("@dokploy/server/db", () => {
|
||||
update: vi.fn(() => chain),
|
||||
delete: vi.fn(() => chain),
|
||||
};
|
||||
const createQueryMock = () => tableMock;
|
||||
|
||||
return {
|
||||
db: {
|
||||
|
||||
@@ -48,6 +48,20 @@ const baseSettings: WebServerSettings = {
|
||||
urlCallback: "",
|
||||
},
|
||||
},
|
||||
whitelabelingConfig: {
|
||||
appName: null,
|
||||
appDescription: null,
|
||||
logoUrl: null,
|
||||
faviconUrl: null,
|
||||
customCss: null,
|
||||
loginLogoUrl: null,
|
||||
supportUrl: null,
|
||||
docsUrl: null,
|
||||
errorPageTitle: null,
|
||||
errorPageDescription: null,
|
||||
metaTitle: null,
|
||||
footerText: null,
|
||||
},
|
||||
cleanupCacheApplications: false,
|
||||
cleanupCacheOnCompose: false,
|
||||
cleanupCacheOnPreviews: false,
|
||||
|
||||
@@ -126,6 +126,7 @@ const baseApp: ApplicationNested = {
|
||||
username: null,
|
||||
dockerContextPath: null,
|
||||
stopGracePeriodSwarm: null,
|
||||
ulimitsSwarm: null,
|
||||
};
|
||||
|
||||
const baseDomain: Domain = {
|
||||
@@ -137,6 +138,7 @@ const baseDomain: Domain = {
|
||||
https: false,
|
||||
path: null,
|
||||
port: null,
|
||||
customEntrypoint: null,
|
||||
serviceName: "",
|
||||
composeId: "",
|
||||
customCertResolver: null,
|
||||
@@ -145,6 +147,7 @@ const baseDomain: Domain = {
|
||||
previewDeploymentId: "",
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
};
|
||||
|
||||
const baseRedirect: Redirect = {
|
||||
@@ -264,6 +267,80 @@ test("Websecure entrypoint on https domain with redirect", async () => {
|
||||
expect(router.middlewares).toContain("redirect-test-1");
|
||||
});
|
||||
|
||||
/** Custom Middlewares */
|
||||
|
||||
test("Web entrypoint with single custom middleware", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, middlewares: ["auth@file"] },
|
||||
"web",
|
||||
);
|
||||
|
||||
expect(router.middlewares).toContain("auth@file");
|
||||
});
|
||||
|
||||
test("Web entrypoint with multiple custom middlewares", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, middlewares: ["auth@file", "rate-limit@file"] },
|
||||
"web",
|
||||
);
|
||||
|
||||
expect(router.middlewares).toContain("auth@file");
|
||||
expect(router.middlewares).toContain("rate-limit@file");
|
||||
});
|
||||
|
||||
test("Web entrypoint on https domain with custom middleware", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
|
||||
"web",
|
||||
);
|
||||
|
||||
// Should only have HTTPS redirect - custom middleware applies on websecure
|
||||
expect(router.middlewares).toContain("redirect-to-https");
|
||||
expect(router.middlewares).not.toContain("auth@file");
|
||||
});
|
||||
|
||||
test("Websecure entrypoint with custom middleware", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
|
||||
"websecure",
|
||||
);
|
||||
|
||||
// Should have custom middleware but not HTTPS redirect
|
||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
||||
expect(router.middlewares).toContain("auth@file");
|
||||
});
|
||||
|
||||
test("Web entrypoint with redirect and custom middleware", async () => {
|
||||
const router = await createRouterConfig(
|
||||
{
|
||||
...baseApp,
|
||||
appName: "test",
|
||||
redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }],
|
||||
},
|
||||
{ ...baseDomain, middlewares: ["auth@file"] },
|
||||
"web",
|
||||
);
|
||||
|
||||
// Should have both redirect middleware and custom middleware
|
||||
expect(router.middlewares).toContain("redirect-test-1");
|
||||
expect(router.middlewares).toContain("auth@file");
|
||||
});
|
||||
|
||||
test("Web entrypoint with empty middlewares array", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, https: false, middlewares: [] },
|
||||
"web",
|
||||
);
|
||||
|
||||
// Should behave same as no middlewares - no redirect for http
|
||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
||||
});
|
||||
|
||||
/** Certificates */
|
||||
|
||||
test("CertificateType on websecure entrypoint", async () => {
|
||||
@@ -275,3 +352,155 @@ test("CertificateType on websecure entrypoint", async () => {
|
||||
|
||||
expect(router.tls?.certResolver).toBe("letsencrypt");
|
||||
});
|
||||
|
||||
test("Custom entrypoint on http domain", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, https: false, customEntrypoint: "custom" },
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(router.entryPoints).toEqual(["custom"]);
|
||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
||||
expect(router.tls).toBeUndefined();
|
||||
});
|
||||
|
||||
test("Custom entrypoint on https domain", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{
|
||||
...baseDomain,
|
||||
https: true,
|
||||
customEntrypoint: "custom",
|
||||
certificateType: "letsencrypt",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(router.entryPoints).toEqual(["custom"]);
|
||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
||||
expect(router.tls?.certResolver).toBe("letsencrypt");
|
||||
});
|
||||
|
||||
test("Custom entrypoint with path includes PathPrefix in rule", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, customEntrypoint: "custom", path: "/api" },
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(router.rule).toContain("PathPrefix(`/api`)");
|
||||
expect(router.entryPoints).toEqual(["custom"]);
|
||||
});
|
||||
|
||||
test("Custom entrypoint with stripPath adds stripprefix middleware", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{
|
||||
...baseDomain,
|
||||
customEntrypoint: "custom",
|
||||
path: "/api",
|
||||
stripPath: true,
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(router.middlewares).toContain("stripprefix--1");
|
||||
expect(router.entryPoints).toEqual(["custom"]);
|
||||
});
|
||||
|
||||
test("Custom entrypoint with internalPath adds addprefix middleware", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{
|
||||
...baseDomain,
|
||||
customEntrypoint: "custom",
|
||||
internalPath: "/hello",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(router.middlewares).toContain("addprefix--1");
|
||||
expect(router.entryPoints).toEqual(["custom"]);
|
||||
});
|
||||
|
||||
test("Custom entrypoint with https and custom cert resolver", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{
|
||||
...baseDomain,
|
||||
https: true,
|
||||
customEntrypoint: "custom",
|
||||
certificateType: "custom",
|
||||
customCertResolver: "myresolver",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(router.entryPoints).toEqual(["custom"]);
|
||||
expect(router.tls?.certResolver).toBe("myresolver");
|
||||
});
|
||||
|
||||
test("Custom entrypoint without https should not have tls", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{
|
||||
...baseDomain,
|
||||
https: false,
|
||||
customEntrypoint: "custom",
|
||||
certificateType: "letsencrypt",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(router.entryPoints).toEqual(["custom"]);
|
||||
expect(router.tls).toBeUndefined();
|
||||
});
|
||||
|
||||
/** IDN/Punycode */
|
||||
|
||||
test("Internationalized domain name is converted to punycode", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, host: "тест.рф" },
|
||||
"web",
|
||||
);
|
||||
|
||||
// тест.рф in punycode is xn--e1aybc.xn--p1ai
|
||||
expect(router.rule).toContain("Host(`xn--e1aybc.xn--p1ai`)");
|
||||
expect(router.rule).not.toContain("тест.рф");
|
||||
});
|
||||
|
||||
test("ASCII domain remains unchanged", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, host: "example.com" },
|
||||
"web",
|
||||
);
|
||||
|
||||
expect(router.rule).toContain("Host(`example.com`)");
|
||||
});
|
||||
|
||||
test("Russian Cyrillic label with .ru TLD is converted to punycode", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, host: "сайт.ru" },
|
||||
"web",
|
||||
);
|
||||
|
||||
// сайт in punycode is xn--80aswg
|
||||
expect(router.rule).toContain("Host(`xn--80aswg.ru`)");
|
||||
expect(router.rule).not.toContain("сайт");
|
||||
});
|
||||
|
||||
test("Subdomain with Russian IDN TLD converts non-ASCII part to punycode", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, host: "app.тест.рф" },
|
||||
"web",
|
||||
);
|
||||
|
||||
// app stays ASCII, тест.рф becomes xn--e1aybc.xn--p1ai
|
||||
expect(router.rule).toContain("Host(`app.xn--e1aybc.xn--p1ai`)");
|
||||
expect(router.rule).not.toContain("тест.рф");
|
||||
});
|
||||
|
||||
81
apps/dokploy/__test__/wss/readValidDirectory.test.ts
Normal file
81
apps/dokploy/__test__/wss/readValidDirectory.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const BASE = "/base";
|
||||
|
||||
vi.mock("@dokploy/server/constants", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@dokploy/server/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
paths: () => ({
|
||||
...actual.paths(),
|
||||
BASE_PATH: BASE,
|
||||
LOGS_PATH: `${BASE}/logs`,
|
||||
APPLICATIONS_PATH: `${BASE}/applications`,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mock so paths() uses our BASE
|
||||
const { readValidDirectory } = await import("@dokploy/server");
|
||||
|
||||
describe("readValidDirectory (path traversal)", () => {
|
||||
it("returns true when directory is exactly BASE_PATH", () => {
|
||||
expect(readValidDirectory(BASE)).toBe(true);
|
||||
expect(readValidDirectory(path.resolve(BASE))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when directory is under BASE_PATH", () => {
|
||||
expect(readValidDirectory(`${BASE}/logs`)).toBe(true);
|
||||
expect(readValidDirectory(`${BASE}/logs/app/foo.log`)).toBe(true);
|
||||
expect(readValidDirectory(`${BASE}/applications/myapp/code`)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for path traversal escaping base (absolute)", () => {
|
||||
expect(readValidDirectory("/etc/passwd")).toBe(false);
|
||||
expect(readValidDirectory("/etc/cron.d/malicious")).toBe(false);
|
||||
expect(readValidDirectory("/tmp/outside")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when resolved path escapes base via ..", () => {
|
||||
// Resolved: /etc/passwd (outside /base)
|
||||
expect(readValidDirectory(`${BASE}/../etc/passwd`)).toBe(false);
|
||||
expect(readValidDirectory(`${BASE}/logs/../../etc/passwd`)).toBe(false);
|
||||
expect(readValidDirectory(`${BASE}/..`)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when .. stays within base", () => {
|
||||
// e.g. /base/logs/../applications -> /base/applications (still under /base)
|
||||
expect(readValidDirectory(`${BASE}/logs/../applications`)).toBe(true);
|
||||
expect(readValidDirectory(`${BASE}/foo/../bar`)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts serverId for remote base path", () => {
|
||||
// With our mock, serverId doesn't change BASE_PATH; just ensure it doesn't throw
|
||||
expect(readValidDirectory(BASE, "server-1")).toBe(true);
|
||||
expect(readValidDirectory("/etc/passwd", "server-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null/undefined-like paths that resolve outside", () => {
|
||||
// Paths that might resolve to cwd or root
|
||||
expect(readValidDirectory(".")).toBe(false);
|
||||
expect(readValidDirectory("..")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for BASE_PATH with trailing slash or double slashes under base", () => {
|
||||
expect(readValidDirectory(`${BASE}/`)).toBe(true);
|
||||
expect(readValidDirectory(`${BASE}//logs`)).toBe(true);
|
||||
expect(readValidDirectory(`${BASE}/applications///myapp/code`)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when path looks like base but is a sibling or prefix", () => {
|
||||
expect(readValidDirectory("/base-evil")).toBe(false);
|
||||
expect(readValidDirectory("/bas")).toBe(false);
|
||||
expect(readValidDirectory(`${BASE}/../base-evil`)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty string (resolves to cwd)", () => {
|
||||
expect(readValidDirectory("")).toBe(false);
|
||||
});
|
||||
});
|
||||
132
apps/dokploy/__test__/wss/utils.test.ts
Normal file
132
apps/dokploy/__test__/wss/utils.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isValidContainerId,
|
||||
isValidSearch,
|
||||
isValidSince,
|
||||
isValidTail,
|
||||
} from "../../server/wss/utils";
|
||||
|
||||
describe("isValidTail (docker-container-logs)", () => {
|
||||
it("accepts valid numeric tail values", () => {
|
||||
expect(isValidTail("0")).toBe(true);
|
||||
expect(isValidTail("1")).toBe(true);
|
||||
expect(isValidTail("100")).toBe(true);
|
||||
expect(isValidTail("10000")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects tail above 10000", () => {
|
||||
expect(isValidTail("10001")).toBe(false);
|
||||
expect(isValidTail("99999")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-numeric tail", () => {
|
||||
expect(isValidTail("")).toBe(false);
|
||||
expect(isValidTail("abc")).toBe(false);
|
||||
expect(isValidTail("10a")).toBe(false);
|
||||
expect(isValidTail("-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects command injection payloads in tail", () => {
|
||||
expect(isValidTail("10; whoami; #")).toBe(false);
|
||||
expect(isValidTail("100 | cat /etc/passwd")).toBe(false);
|
||||
expect(isValidTail("$(id)")).toBe(false);
|
||||
expect(isValidTail("`id`")).toBe(false);
|
||||
expect(isValidTail("100\nid")).toBe(false);
|
||||
expect(isValidTail("100 && id")).toBe(false);
|
||||
expect(isValidTail("100; env | grep DATABASE")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidSince (docker-container-logs)", () => {
|
||||
it("accepts 'all'", () => {
|
||||
expect(isValidSince("all")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts valid duration format (number + s|m|h|d)", () => {
|
||||
expect(isValidSince("5s")).toBe(true);
|
||||
expect(isValidSince("10m")).toBe(true);
|
||||
expect(isValidSince("1h")).toBe(true);
|
||||
expect(isValidSince("2d")).toBe(true);
|
||||
expect(isValidSince("0s")).toBe(true);
|
||||
expect(isValidSince("999d")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid duration format", () => {
|
||||
expect(isValidSince("")).toBe(false);
|
||||
expect(isValidSince("5")).toBe(false);
|
||||
expect(isValidSince("s")).toBe(false);
|
||||
expect(isValidSince("5x")).toBe(false);
|
||||
expect(isValidSince("5sec")).toBe(false);
|
||||
expect(isValidSince("5 m")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects command injection payloads in since", () => {
|
||||
expect(isValidSince("5s; whoami")).toBe(false);
|
||||
expect(isValidSince("all; id")).toBe(false);
|
||||
expect(isValidSince("1m$(id)")).toBe(false);
|
||||
expect(isValidSince("1m | cat /etc/passwd")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidSearch (docker-container-logs)", () => {
|
||||
it("accepts empty string", () => {
|
||||
expect(isValidSearch("")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts only alphanumeric, space, dot, underscore, hyphen", () => {
|
||||
expect(isValidSearch("error")).toBe(true);
|
||||
expect(isValidSearch("foo bar")).toBe(true);
|
||||
expect(isValidSearch("a-zA-Z0-9_.-")).toBe(true);
|
||||
expect(isValidSearch("")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects strings longer than 500 chars", () => {
|
||||
expect(isValidSearch("a".repeat(501))).toBe(false);
|
||||
expect(isValidSearch("a".repeat(500))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects control characters and non-printable", () => {
|
||||
expect(isValidSearch("foo\nbar")).toBe(false);
|
||||
expect(isValidSearch("foo\rbar")).toBe(false);
|
||||
expect(isValidSearch("\x00")).toBe(false);
|
||||
expect(isValidSearch("a\x19b")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects command injection vectors in search (search is concatenated into shell)", () => {
|
||||
// Double-quoted context (SSH line 99): $ and ` execute
|
||||
expect(isValidSearch("$(whoami)")).toBe(false);
|
||||
expect(isValidSearch("`id`")).toBe(false);
|
||||
expect(isValidSearch("$(id)")).toBe(false);
|
||||
// Single-quoted context (local line 153): ' breaks out
|
||||
expect(isValidSearch("'$(whoami)'")).toBe(false);
|
||||
expect(isValidSearch("error'")).toBe(false);
|
||||
expect(isValidSearch("'; whoami; #")).toBe(false);
|
||||
// Other shell-metacharacters
|
||||
expect(isValidSearch("error; id")).toBe(false);
|
||||
expect(isValidSearch("a|b")).toBe(false);
|
||||
expect(isValidSearch('error"')).toBe(false);
|
||||
expect(isValidSearch("a&b")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidContainerId (docker-container-logs)", () => {
|
||||
it("accepts valid hex container IDs", () => {
|
||||
expect(isValidContainerId("a".repeat(12))).toBe(true);
|
||||
expect(isValidContainerId("abc123def456")).toBe(true);
|
||||
expect(isValidContainerId("a".repeat(64))).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts valid container names", () => {
|
||||
expect(isValidContainerId("my-container")).toBe(true);
|
||||
expect(isValidContainerId("app_1")).toBe(true);
|
||||
expect(isValidContainerId("service.name")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects command injection in container ID", () => {
|
||||
expect(isValidContainerId("dummy; whoami")).toBe(false);
|
||||
expect(isValidContainerId("$(id)")).toBe(false);
|
||||
expect(isValidContainerId("`id`")).toBe(false);
|
||||
expect(isValidContainerId("container|cat /etc/passwd")).toBe(false);
|
||||
expect(isValidContainerId("x; env | grep DATABASE")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
HealthCheckForm,
|
||||
LabelsForm,
|
||||
ModeForm,
|
||||
NetworkForm,
|
||||
PlacementForm,
|
||||
RestartPolicyForm,
|
||||
RollbackConfigForm,
|
||||
@@ -79,6 +80,13 @@ const menuItems: MenuItem[] = [
|
||||
docDescription:
|
||||
"Set service mode to either 'Replicated' with a specified number of tasks (Replicas), or 'Global' (one task per node).",
|
||||
},
|
||||
{
|
||||
id: "network",
|
||||
label: "Network",
|
||||
description: "Configure network attachments",
|
||||
docDescription:
|
||||
"Attach the service to one or more networks. Specify the network name (Target) and optional network aliases for service discovery.",
|
||||
},
|
||||
{
|
||||
id: "labels",
|
||||
label: "Labels",
|
||||
@@ -102,16 +110,16 @@ const menuItems: MenuItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const hasStopGracePeriodSwarm = (
|
||||
value: unknown,
|
||||
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"stopGracePeriodSwarm" in value;
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "application"
|
||||
| "libsql"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "postgres"
|
||||
| "redis";
|
||||
}
|
||||
|
||||
export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
@@ -190,6 +198,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
<RollbackConfigForm id={id} type={type} />
|
||||
)}
|
||||
{activeMenu === "mode" && <ModeForm id={id} type={type} />}
|
||||
{activeMenu === "network" && <NetworkForm id={id} type={type} />}
|
||||
{activeMenu === "labels" && <LabelsForm id={id} type={type} />}
|
||||
{activeMenu === "stop-grace-period" && (
|
||||
<StopGracePeriodForm id={id} type={type} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Server } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
@@ -37,27 +37,27 @@ import { AddSwarmSettings } from "./modify-swarm-settings";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type: "application" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis";
|
||||
}
|
||||
|
||||
const AddRedirectchema = z.object({
|
||||
const AddRedirectSchema = z.object({
|
||||
replicas: z.number().min(1, "Replicas must be at least 1"),
|
||||
registryId: z.string().optional(),
|
||||
});
|
||||
|
||||
type AddCommand = z.infer<typeof AddRedirectchema>;
|
||||
type AddCommand = z.infer<typeof AddRedirectSchema>;
|
||||
|
||||
export const ShowClusterSettings = ({ id, type }: Props) => {
|
||||
const queryMap = {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -65,15 +65,16 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
||||
const { data: registries } = api.registry.all.useQuery();
|
||||
|
||||
const mutationMap = {
|
||||
application: () => api.application.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync, isLoading } = mutationMap[type]
|
||||
const { mutateAsync, isPending } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.mongo.update.useMutation();
|
||||
|
||||
@@ -86,7 +87,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
||||
: {}),
|
||||
replicas: data?.replicas || 1,
|
||||
},
|
||||
resolver: zodResolver(AddRedirectchema),
|
||||
resolver: zodResolver(AddRedirectSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -105,11 +106,11 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
||||
const onSubmit = async (data: AddCommand) => {
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
mysqlId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
...(type === "application"
|
||||
? {
|
||||
registryId:
|
||||
@@ -236,7 +237,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button isLoading={isLoading} type="submit" className="w-fit">
|
||||
<Button isLoading={isPending} type="submit" className="w-fit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -28,7 +28,14 @@ export const endpointSpecFormSchema = z.object({
|
||||
|
||||
interface EndpointSpecFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
||||
@@ -44,6 +51,7 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -56,6 +64,7 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -94,6 +103,7 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
endpointSpecSwarm: hasAnyValue ? formData : null,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -26,7 +26,14 @@ export const healthCheckFormSchema = z.object({
|
||||
|
||||
interface HealthCheckFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
@@ -42,6 +49,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -54,6 +62,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -104,6 +113,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
healthCheckSwarm: hasAnyValue ? formData : null,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ export { EndpointSpecForm } from "./endpoint-spec-form";
|
||||
export { HealthCheckForm } from "./health-check-form";
|
||||
export { LabelsForm } from "./labels-form";
|
||||
export { ModeForm } from "./mode-form";
|
||||
export { NetworkForm } from "./network-form";
|
||||
export { PlacementForm } from "./placement-form";
|
||||
export { RestartPolicyForm } from "./restart-policy-form";
|
||||
export { RollbackConfigForm } from "./rollback-config-form";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -29,7 +29,14 @@ export const labelsFormSchema = z.object({
|
||||
|
||||
interface LabelsFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
||||
@@ -45,6 +52,7 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -57,6 +65,7 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -112,6 +121,7 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
labelsSwarm: labelsToSend,
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,14 @@ import { api } from "@/utils/api";
|
||||
|
||||
interface ModeFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const ModeForm = ({ id, type }: ModeFormProps) => {
|
||||
@@ -39,6 +46,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -51,6 +59,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -95,6 +104,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
modeSwarm: null,
|
||||
});
|
||||
toast.success("Mode updated successfully");
|
||||
@@ -105,7 +115,14 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
||||
|
||||
const modeData =
|
||||
formData.type === "Replicated"
|
||||
? { Replicated: { Replicas: formData.Replicas } }
|
||||
? {
|
||||
Replicated: {
|
||||
Replicas:
|
||||
formData.Replicas !== undefined && formData.Replicas !== ""
|
||||
? Number(formData.Replicas)
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
: { Global: {} };
|
||||
|
||||
await mutateAsync({
|
||||
@@ -115,6 +132,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
modeSwarm: modeData,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const driverOptEntrySchema = z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const networkFormSchema = z.object({
|
||||
networks: z
|
||||
.array(
|
||||
z.object({
|
||||
Target: z.string().optional(),
|
||||
Aliases: z.string().optional(),
|
||||
DriverOptsEntries: z.array(driverOptEntrySchema).optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
interface NetworkFormProps {
|
||||
id: string;
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const queryMap = {
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||
|
||||
const mutationMap = {
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.mongo.update.useMutation();
|
||||
|
||||
const form = useForm<z.infer<typeof networkFormSchema>>({
|
||||
resolver: zodResolver(networkFormSchema),
|
||||
defaultValues: {
|
||||
networks: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "networks",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.networkSwarm && Array.isArray(data.networkSwarm)) {
|
||||
const networkEntries = data.networkSwarm.map((network) => ({
|
||||
Target: network.Target || "",
|
||||
Aliases: network.Aliases?.join(", ") || "",
|
||||
DriverOptsEntries: network.DriverOpts
|
||||
? Object.entries(network.DriverOpts).map(([key, value]) => ({
|
||||
key,
|
||||
value: value ?? "",
|
||||
}))
|
||||
: [],
|
||||
}));
|
||||
form.reset({ networks: networkEntries });
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (formData: z.infer<typeof networkFormSchema>) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const networksArray =
|
||||
formData.networks
|
||||
?.filter((network) => network.Target)
|
||||
.map((network) => {
|
||||
const entries = (network.DriverOptsEntries ?? []).filter(
|
||||
(e) => e.key.trim() !== "",
|
||||
);
|
||||
const driverOpts =
|
||||
entries.length > 0
|
||||
? Object.fromEntries(
|
||||
entries.map((e) => [e.key.trim(), e.value]),
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
Target: network.Target,
|
||||
Aliases: network.Aliases
|
||||
? network.Aliases.split(",").map((alias) => alias.trim())
|
||||
: undefined,
|
||||
DriverOpts: driverOpts,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
// If no networks, send null to clear the database
|
||||
const networksToSend = networksArray.length > 0 ? networksArray : null;
|
||||
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
networkSwarm: networksToSend,
|
||||
});
|
||||
|
||||
toast.success("Network configuration updated successfully");
|
||||
refetch();
|
||||
} catch {
|
||||
toast.error("Error updating network configuration");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<FormLabel>Networks</FormLabel>
|
||||
<FormDescription>
|
||||
Configure network attachments for your service
|
||||
</FormDescription>
|
||||
<div className="space-y-2 mt-2">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="space-y-2 p-3 border rounded">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`networks.${index}.Target`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Network Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="my-network" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The name of the network to attach to
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`networks.${index}.Aliases`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Aliases (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="alias1, alias2, alias3"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Comma-separated list of network aliases
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<FormLabel>Driver options (optional)</FormLabel>
|
||||
<FormDescription>
|
||||
e.g. com.docker.network.driver.mtu,
|
||||
com.docker.network.driver.host_binding
|
||||
</FormDescription>
|
||||
{(
|
||||
form.watch(`networks.${index}.DriverOptsEntries`) ?? []
|
||||
).map((_, optIndex) => (
|
||||
<div
|
||||
key={optIndex}
|
||||
className="flex gap-2 items-end flex-wrap"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`networks.${index}.DriverOptsEntries.${optIndex}.key`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1 min-w-[140px]">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="com.docker.network.driver.mtu"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`networks.${index}.DriverOptsEntries.${optIndex}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1 min-w-[100px]">
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="1500" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const entries =
|
||||
form.getValues(
|
||||
`networks.${index}.DriverOptsEntries`,
|
||||
) ?? [];
|
||||
form.setValue(
|
||||
`networks.${index}.DriverOptsEntries`,
|
||||
entries.filter((_, i) => i !== optIndex),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const entries =
|
||||
form.getValues(`networks.${index}.DriverOptsEntries`) ??
|
||||
[];
|
||||
form.setValue(`networks.${index}.DriverOptsEntries`, [
|
||||
...entries,
|
||||
{ key: "", value: "" },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
Add driver option
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
Remove Network
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
append({
|
||||
Target: "",
|
||||
Aliases: "",
|
||||
DriverOptsEntries: [],
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Network
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
form.reset({ networks: [] });
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save Networks
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -34,7 +34,14 @@ export const placementFormSchema = z.object({
|
||||
|
||||
interface PlacementFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
||||
@@ -50,6 +57,7 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -62,6 +70,7 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -114,6 +123,7 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
placementSwarm: hasAnyValue
|
||||
? {
|
||||
...formData,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -32,7 +32,14 @@ export const restartPolicyFormSchema = z.object({
|
||||
|
||||
interface RestartPolicyFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
||||
@@ -48,6 +55,7 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -60,6 +68,7 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -104,6 +113,7 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
restartPolicySwarm: hasAnyValue ? formData : null,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -34,7 +34,14 @@ export const rollbackConfigFormSchema = z.object({
|
||||
|
||||
interface RollbackConfigFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
||||
@@ -50,6 +57,7 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -62,6 +70,7 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -103,6 +112,7 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
rollbackConfigSwarm: (hasAnyValue ? formData : null) as any,
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,14 @@ const hasStopGracePeriodSwarm = (
|
||||
|
||||
interface StopGracePeriodFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||
@@ -39,6 +46,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -51,6 +59,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -88,6 +97,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
stopGracePeriodSwarm: formData.value,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -34,7 +34,14 @@ export const updateConfigFormSchema = z.object({
|
||||
|
||||
interface UpdateConfigFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
||||
@@ -50,6 +57,7 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -62,6 +70,7 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -109,6 +118,7 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
updateConfigSwarm: (hasAnyValue ? formData : null) as any,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
@@ -50,7 +50,7 @@ export const AddCommand = ({ applicationId }: Props) => {
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { mutateAsync, isLoading } = api.application.update.useMutation();
|
||||
const { mutateAsync, isPending } = api.application.update.useMutation();
|
||||
|
||||
const form = useForm<AddCommand>({
|
||||
defaultValues: {
|
||||
@@ -177,7 +177,7 @@ export const AddCommand = ({ applicationId }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button isLoading={isLoading} type="submit" className="w-fit">
|
||||
<Button isLoading={isPending} type="submit" className="w-fit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Code2, Globe2, HardDrive } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -69,11 +69,11 @@ export const ShowImport = ({ composeId }: Props) => {
|
||||
} | null>(null);
|
||||
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync: processTemplate, isLoading: isLoadingTemplate } =
|
||||
const { mutateAsync: processTemplate, isPending: isLoadingTemplate } =
|
||||
api.compose.processTemplate.useMutation();
|
||||
const {
|
||||
mutateAsync: importTemplate,
|
||||
isLoading: isImporting,
|
||||
isPending: isImporting,
|
||||
isSuccess: isImportSuccess,
|
||||
} = api.compose.import.useMutation();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
@@ -35,13 +35,9 @@ import { api } from "@/utils/api";
|
||||
|
||||
const AddPortSchema = z.object({
|
||||
publishedPort: z.number().int().min(1).max(65535),
|
||||
publishMode: z.enum(["ingress", "host"], {
|
||||
required_error: "Publish mode is required",
|
||||
}),
|
||||
publishMode: z.enum(["ingress", "host"]),
|
||||
targetPort: z.number().int().min(1).max(65535),
|
||||
protocol: z.enum(["tcp", "udp"], {
|
||||
required_error: "Protocol is required",
|
||||
}),
|
||||
protocol: z.enum(["tcp", "udp"]),
|
||||
});
|
||||
|
||||
type AddPort = z.infer<typeof AddPortSchema>;
|
||||
@@ -68,7 +64,7 @@ export const HandlePorts = ({
|
||||
enabled: !!portId,
|
||||
},
|
||||
);
|
||||
const { mutateAsync, isLoading, error, isError } = portId
|
||||
const { mutateAsync, isPending, error, isError } = portId
|
||||
? api.port.update.useMutation()
|
||||
: api.port.create.useMutation();
|
||||
|
||||
@@ -270,7 +266,7 @@ export const HandlePorts = ({
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
isLoading={isPending}
|
||||
form="hook-form-add-port"
|
||||
type="submit"
|
||||
>
|
||||
|
||||
@@ -25,7 +25,7 @@ export const ShowPorts = ({ applicationId }: Props) => {
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
|
||||
const { mutateAsync: deletePort, isLoading: isRemoving } =
|
||||
const { mutateAsync: deletePort, isPending: isRemoving } =
|
||||
api.port.delete.useMutation();
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -37,13 +37,13 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const AddRedirectchema = z.object({
|
||||
const AddRedirectSchema = z.object({
|
||||
regex: z.string().min(1, "Regex required"),
|
||||
permanent: z.boolean().default(false),
|
||||
replacement: z.string().min(1, "Replacement required"),
|
||||
});
|
||||
|
||||
type AddRedirect = z.infer<typeof AddRedirectchema>;
|
||||
type AddRedirect = z.infer<typeof AddRedirectSchema>;
|
||||
|
||||
// Default presets
|
||||
const redirectPresets = [
|
||||
@@ -100,17 +100,17 @@ export const HandleRedirect = ({
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } = redirectId
|
||||
const { mutateAsync, isPending, error, isError } = redirectId
|
||||
? api.redirects.update.useMutation()
|
||||
: api.redirects.create.useMutation();
|
||||
|
||||
const form = useForm<AddRedirect>({
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
permanent: false,
|
||||
regex: "",
|
||||
replacement: "",
|
||||
},
|
||||
resolver: zodResolver(AddRedirectchema),
|
||||
resolver: zodResolver(AddRedirectSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -149,7 +149,7 @@ export const HandleRedirect = ({
|
||||
|
||||
const onDialogToggle = (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
// commented for the moment because not reseting the form if accidentally closed the dialog can be considered as a feature instead of a bug
|
||||
// commented for the moment because not resetting the form if accidentally closed the dialog can be considered as a feature instead of a bug
|
||||
// setPresetSelected("");
|
||||
// form.reset();
|
||||
};
|
||||
@@ -268,7 +268,7 @@ export const HandleRedirect = ({
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
isLoading={isPending}
|
||||
form="hook-form-add-redirect"
|
||||
type="submit"
|
||||
>
|
||||
|
||||
@@ -24,7 +24,7 @@ export const ShowRedirects = ({ applicationId }: Props) => {
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
|
||||
const { mutateAsync: deleteRedirect, isLoading: isRemoving } =
|
||||
const { mutateAsync: deleteRedirect, isPending: isRemoving } =
|
||||
api.redirects.delete.useMutation();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -46,7 +46,7 @@ export const HandleSecurity = ({
|
||||
}: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data } = api.security.one.useQuery(
|
||||
const { data, refetch } = api.security.one.useQuery(
|
||||
{
|
||||
securityId: securityId ?? "",
|
||||
},
|
||||
@@ -55,7 +55,7 @@ export const HandleSecurity = ({
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } = securityId
|
||||
const { mutateAsync, isPending, error, isError } = securityId
|
||||
? api.security.update.useMutation()
|
||||
: api.security.create.useMutation();
|
||||
|
||||
@@ -88,6 +88,7 @@ export const HandleSecurity = ({
|
||||
await utils.application.readTraefikConfig.invalidate({
|
||||
applicationId,
|
||||
});
|
||||
await refetch();
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -163,7 +164,7 @@ export const HandleSecurity = ({
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
isLoading={isPending}
|
||||
form="hook-form-add-security"
|
||||
type="submit"
|
||||
>
|
||||
|
||||
@@ -27,7 +27,7 @@ export const ShowSecurity = ({ applicationId }: Props) => {
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
|
||||
const { mutateAsync: deleteSecurity, isLoading: isRemoving } =
|
||||
const { mutateAsync: deleteSecurity, isPending: isRemoving } =
|
||||
api.security.delete.useMutation();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Server } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
@@ -74,7 +74,7 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
|
||||
const { data: buildServers } = api.server.buildServers.useQuery();
|
||||
const { data: registries } = api.registry.all.useQuery();
|
||||
|
||||
const { mutateAsync, isLoading } = api.application.update.useMutation();
|
||||
const { mutateAsync, isPending } = api.application.update.useMutation();
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
@@ -274,7 +274,7 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
|
||||
/>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
<Button isLoading={isPending} type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { InfoIcon, Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
@@ -21,10 +21,18 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
createConverter,
|
||||
NumberInputWithSteps,
|
||||
} from "@/components/ui/number-input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -50,20 +58,44 @@ const memoryConverter = createConverter(1024 * 1024, (mb) => {
|
||||
: `${formatNumber(mb)} MB`;
|
||||
});
|
||||
|
||||
const ulimitSchema = z.object({
|
||||
Name: z.string().min(1, "Name is required"),
|
||||
Soft: z.coerce.number().int().min(-1, "Must be >= -1"),
|
||||
Hard: z.coerce.number().int().min(-1, "Must be >= -1"),
|
||||
});
|
||||
|
||||
const addResourcesSchema = z.object({
|
||||
memoryReservation: z.string().optional(),
|
||||
cpuLimit: z.string().optional(),
|
||||
memoryLimit: z.string().optional(),
|
||||
cpuReservation: z.string().optional(),
|
||||
ulimitsSwarm: z.array(ulimitSchema).optional(),
|
||||
});
|
||||
|
||||
const ULIMIT_PRESETS = [
|
||||
{ value: "nofile", label: "nofile (Open Files)" },
|
||||
{ value: "nproc", label: "nproc (Processes)" },
|
||||
{ value: "memlock", label: "memlock (Locked Memory)" },
|
||||
{ value: "stack", label: "stack (Stack Size)" },
|
||||
{ value: "core", label: "core (Core File Size)" },
|
||||
{ value: "cpu", label: "cpu (CPU Time)" },
|
||||
{ value: "data", label: "data (Data Segment)" },
|
||||
{ value: "fsize", label: "fsize (File Size)" },
|
||||
{ value: "locks", label: "locks (File Locks)" },
|
||||
{ value: "msgqueue", label: "msgqueue (Message Queues)" },
|
||||
{ value: "nice", label: "nice (Nice Priority)" },
|
||||
{ value: "rtprio", label: "rtprio (Real-time Priority)" },
|
||||
{ value: "sigpending", label: "sigpending (Pending Signals)" },
|
||||
];
|
||||
|
||||
export type ServiceType =
|
||||
| "postgres"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "mysql"
|
||||
| "application"
|
||||
| "libsql"
|
||||
| "mariadb"
|
||||
| "application";
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "postgres"
|
||||
| "redis";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
@@ -74,43 +106,51 @@ type AddResources = z.infer<typeof addResourcesSchema>;
|
||||
|
||||
export const ShowResources = ({ id, type }: Props) => {
|
||||
const queryMap = {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||
|
||||
const mutationMap = {
|
||||
application: () => api.application.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync, isLoading } = mutationMap[type]
|
||||
const { mutateAsync, isPending } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.mongo.update.useMutation();
|
||||
|
||||
const form = useForm<AddResources>({
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
cpuLimit: "",
|
||||
cpuReservation: "",
|
||||
memoryLimit: "",
|
||||
memoryReservation: "",
|
||||
ulimitsSwarm: [],
|
||||
},
|
||||
resolver: zodResolver(addResourcesSchema),
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "ulimitsSwarm",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
@@ -118,22 +158,28 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
cpuReservation: data?.cpuReservation || undefined,
|
||||
memoryLimit: data?.memoryLimit || undefined,
|
||||
memoryReservation: data?.memoryReservation || undefined,
|
||||
ulimitsSwarm: (data as any)?.ulimitsSwarm || [],
|
||||
});
|
||||
}
|
||||
}, [data, form, form.reset]);
|
||||
|
||||
const onSubmit = async (formData: AddResources) => {
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
libsqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
mysqlId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
applicationId: id || "",
|
||||
cpuLimit: formData.cpuLimit || null,
|
||||
cpuReservation: formData.cpuReservation || null,
|
||||
memoryLimit: formData.memoryLimit || null,
|
||||
memoryReservation: formData.memoryReservation || null,
|
||||
ulimitsSwarm:
|
||||
formData.ulimitsSwarm && formData.ulimitsSwarm.length > 0
|
||||
? formData.ulimitsSwarm
|
||||
: null,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Resources Updated");
|
||||
@@ -325,8 +371,157 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ulimits Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="text-base">Ulimits</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p>
|
||||
Set resource limits for the container. Each ulimit has
|
||||
a soft limit (warning threshold) and hard limit
|
||||
(maximum allowed). Use -1 for unlimited.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
append({ Name: "nofile", Soft: 65535, Hard: 65535 })
|
||||
}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Ulimit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex items-start gap-3 p-3 border rounded-lg bg-muted/30"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ulimitsSwarm.${index}.Name`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel className="text-xs">Type</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select ulimit" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{ULIMIT_PRESETS.map((preset) => (
|
||||
<SelectItem
|
||||
key={preset.value}
|
||||
value={preset.value}
|
||||
>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ulimitsSwarm.${index}.Soft`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-32">
|
||||
<FormLabel className="text-xs">
|
||||
Soft Limit
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={-1}
|
||||
placeholder="65535"
|
||||
{...field}
|
||||
value={
|
||||
typeof field.value === "number"
|
||||
? field.value
|
||||
: ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
field.onChange(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ulimitsSwarm.${index}.Hard`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-32">
|
||||
<FormLabel className="text-xs">
|
||||
Hard Limit
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={-1}
|
||||
placeholder="65535"
|
||||
{...field}
|
||||
value={
|
||||
typeof field.value === "number"
|
||||
? field.value
|
||||
: ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
field.onChange(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-6 text-destructive hover:text-destructive"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No ulimits configured. Click "Add Ulimit" to set
|
||||
resource limits.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
<Button isLoading={isPending} type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -15,13 +15,17 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowTraefikConfig = ({ applicationId }: Props) => {
|
||||
const { data, isLoading } = api.application.readTraefikConfig.useQuery(
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canRead = permissions?.traefikFiles.read ?? false;
|
||||
const { data, isPending } = api.application.readTraefikConfig.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{ enabled: !!applicationId },
|
||||
{ enabled: !!applicationId && canRead },
|
||||
);
|
||||
|
||||
if (!canRead) return null;
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row justify-between">
|
||||
@@ -35,7 +39,7 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{isLoading ? (
|
||||
{isPending ? (
|
||||
<span className="text-base text-muted-foreground flex flex-row gap-3 items-center justify-center min-h-[10vh]">
|
||||
Loading...
|
||||
<Loader2 className="animate-spin" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -7,6 +7,7 @@ import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const UpdateTraefikConfigSchema = z.object({
|
||||
@@ -58,7 +60,10 @@ export const validateAndFormatYAML = (yamlText: string) => {
|
||||
};
|
||||
|
||||
export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canWrite = permissions?.traefikFiles.write ?? false;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
|
||||
const { data, refetch } = api.application.readTraefikConfig.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
@@ -66,7 +71,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
const { mutateAsync, isPending, error, isError } =
|
||||
api.application.updateTraefikConfig.useMutation();
|
||||
|
||||
const form = useForm<UpdateTraefikConfig>({
|
||||
@@ -85,13 +90,15 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
}, [data]);
|
||||
|
||||
const onSubmit = async (data: UpdateTraefikConfig) => {
|
||||
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: (error as string) || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
if (!skipYamlValidation) {
|
||||
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: (error as string) || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
form.clearErrors("traefikConfig");
|
||||
await mutateAsync({
|
||||
@@ -116,12 +123,15 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
setOpen(open);
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setSkipYamlValidation(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button isLoading={isLoading}>Modify</Button>
|
||||
</DialogTrigger>
|
||||
{canWrite && (
|
||||
<DialogTrigger asChild>
|
||||
<Button isLoading={isPending}>Modify</Button>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
<DialogContent className="sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update traefik config</DialogTitle>
|
||||
@@ -169,9 +179,30 @@ routers:
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter className="flex-col sm:flex-row gap-4">
|
||||
<div className="flex flex-col gap-1 w-full sm:w-auto sm:mr-auto">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="skip-yaml-validation-app"
|
||||
checked={skipYamlValidation}
|
||||
onCheckedChange={(checked) =>
|
||||
setSkipYamlValidation(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="skip-yaml-validation-app"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Skip YAML validation (for Go templating)
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Check to save configs with Go templating (e.g.{" "}
|
||||
<code className="text-xs">{"{{range}}"}</code>).
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
isLoading={isPending}
|
||||
form="hook-form-update-traefik-config"
|
||||
type="submit"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -34,13 +34,13 @@ interface Props {
|
||||
serviceId: string;
|
||||
serviceType:
|
||||
| "application"
|
||||
| "postgres"
|
||||
| "redis"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "mysql"
|
||||
| "compose"
|
||||
| "libsql"
|
||||
| "mariadb"
|
||||
| "compose";
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "postgres"
|
||||
| "redis";
|
||||
refetch: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -21,24 +21,33 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowVolumes = ({ id, type }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canRead = permissions?.volume.read ?? false;
|
||||
const canCreate = permissions?.volume.create ?? false;
|
||||
const canDelete = permissions?.volume.delete ?? false;
|
||||
|
||||
if (!canRead) return null;
|
||||
|
||||
const queryMap = {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
compose: () =>
|
||||
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
compose: () =>
|
||||
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||
const { mutateAsync: deleteVolume, isLoading: isRemoving } =
|
||||
const { mutateAsync: deleteVolume, isPending: isRemoving } =
|
||||
api.mounts.remove.useMutation();
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
|
||||
@@ -50,7 +59,7 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{data && data?.mounts.length > 0 && (
|
||||
{canCreate && data && data?.mounts.length > 0 && (
|
||||
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
||||
Add Volume
|
||||
</AddVolumes>
|
||||
@@ -63,9 +72,11 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
||||
<span className="text-base text-muted-foreground">
|
||||
No volumes/mounts configured
|
||||
</span>
|
||||
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
||||
Add Volume
|
||||
</AddVolumes>
|
||||
{canCreate && (
|
||||
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
||||
Add Volume
|
||||
</AddVolumes>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col pt-2 gap-4">
|
||||
@@ -130,38 +141,42 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-1">
|
||||
<UpdateVolume
|
||||
mountId={mount.mountId}
|
||||
type={mount.type}
|
||||
refetch={refetch}
|
||||
serviceType={type}
|
||||
/>
|
||||
<DialogAction
|
||||
title="Delete Volume"
|
||||
description="Are you sure you want to delete this volume?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteVolume({
|
||||
mountId: mount.mountId,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success("Volume deleted successfully");
|
||||
{canCreate && (
|
||||
<UpdateVolume
|
||||
mountId={mount.mountId}
|
||||
type={mount.type}
|
||||
refetch={refetch}
|
||||
serviceType={type}
|
||||
/>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DialogAction
|
||||
title="Delete Volume"
|
||||
description="Are you sure you want to delete this volume?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteVolume({
|
||||
mountId: mount.mountId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting volume");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success("Volume deleted successfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting volume");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -67,13 +67,13 @@ interface Props {
|
||||
refetch: () => void;
|
||||
serviceType:
|
||||
| "application"
|
||||
| "postgres"
|
||||
| "redis"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "mysql"
|
||||
| "compose"
|
||||
| "libsql"
|
||||
| "mariadb"
|
||||
| "compose";
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "postgres"
|
||||
| "redis";
|
||||
}
|
||||
|
||||
export const UpdateVolume = ({
|
||||
@@ -93,7 +93,7 @@ export const UpdateVolume = ({
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
const { mutateAsync, isPending, error, isError } =
|
||||
api.mounts.update.useMutation();
|
||||
|
||||
const form = useForm<UpdateMount>({
|
||||
@@ -187,7 +187,7 @@ export const UpdateVolume = ({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10 "
|
||||
isLoading={isLoading}
|
||||
isLoading={isPending}
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
@@ -253,7 +253,7 @@ export const UpdateVolume = ({
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem className="max-w-full max-w-[45rem]">
|
||||
<FormItem className="w-full max-w-[45rem]">
|
||||
<FormLabel>Content</FormLabel>
|
||||
<FormControl>
|
||||
<FormControl>
|
||||
@@ -310,7 +310,7 @@ PORT=3000
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
isLoading={isPending}
|
||||
// form="hook-form-update-volume"
|
||||
type="submit"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Cog } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -74,12 +74,7 @@ const buildTypeDisplayMap: Record<BuildType, string> = {
|
||||
const mySchema = z.discriminatedUnion("buildType", [
|
||||
z.object({
|
||||
buildType: z.literal(BuildType.dockerfile),
|
||||
dockerfile: z
|
||||
.string({
|
||||
required_error: "Dockerfile path is required",
|
||||
invalid_type_error: "Dockerfile path is required",
|
||||
})
|
||||
.min(1, "Dockerfile required"),
|
||||
dockerfile: z.string().nullable().default(""),
|
||||
dockerContextPath: z.string().nullable().default(""),
|
||||
dockerBuildStage: z.string().nullable().default(""),
|
||||
}),
|
||||
@@ -168,14 +163,14 @@ const resetData = (data: ApplicationData): AddTemplate => {
|
||||
};
|
||||
|
||||
export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
const { mutateAsync, isLoading } =
|
||||
const { mutateAsync, isPending } =
|
||||
api.application.saveBuildType.useMutation();
|
||||
const { data, refetch } = api.application.one.useQuery(
|
||||
{ applicationId },
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
|
||||
const form = useForm<AddTemplate>({
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
buildType: BuildType.nixpacks,
|
||||
},
|
||||
@@ -347,7 +342,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
<FormLabel>Docker File</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Path of your docker file"
|
||||
placeholder="Path of your docker file (default: Dockerfile)"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
@@ -533,7 +528,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
</>
|
||||
)}
|
||||
<div className="flex w-full justify-end">
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
<Button isLoading={isPending} type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Paintbrush } from "lucide-react";
|
||||
import { Ban } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -20,7 +20,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const CancelQueues = ({ id, type }: Props) => {
|
||||
const { mutateAsync, isLoading } =
|
||||
const { mutateAsync, isPending } =
|
||||
type === "application"
|
||||
? api.application.cleanQueues.useMutation()
|
||||
: api.compose.cleanQueues.useMutation();
|
||||
@@ -33,9 +33,9 @@ export const CancelQueues = ({ id, type }: Props) => {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="w-fit" isLoading={isLoading}>
|
||||
<Button variant="destructive" className="w-fit" isLoading={isPending}>
|
||||
Cancel Queues
|
||||
<Paintbrush className="size-4" />
|
||||
<Ban className="size-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Paintbrush } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: "application" | "compose";
|
||||
}
|
||||
|
||||
export const ClearDeployments = ({ id, type }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, isPending } =
|
||||
type === "application"
|
||||
? api.application.clearDeployments.useMutation()
|
||||
: api.compose.clearDeployments.useMutation();
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" className="w-fit" isLoading={isPending}>
|
||||
Clear deployments
|
||||
<Paintbrush className="size-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure you want to clear old deployments?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will delete all old deployment records and logs, keeping only
|
||||
the active deployment (the most recent successful one).
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
composeId: id || "",
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Old deployments cleared successfully");
|
||||
await utils.deployment.allByType.invalidate({
|
||||
id,
|
||||
type: type as "application" | "compose",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -20,7 +20,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const KillBuild = ({ id, type }: Props) => {
|
||||
const { mutateAsync, isLoading } =
|
||||
const { mutateAsync, isPending } =
|
||||
type === "application"
|
||||
? api.application.killBuild.useMutation()
|
||||
: api.compose.killBuild.useMutation();
|
||||
@@ -28,7 +28,7 @@ export const KillBuild = ({ id, type }: Props) => {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" className="w-fit" isLoading={isLoading}>
|
||||
<Button variant="outline" className="w-fit" isLoading={isPending}>
|
||||
Kill Build
|
||||
<Scissors className="size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -194,13 +194,21 @@ export const ShowDeployment = ({
|
||||
{" "}
|
||||
{filteredLogs.length > 0 ? (
|
||||
filteredLogs.map((log: LogLine, index: number) => (
|
||||
<TerminalLine key={index} log={log} noTimestamp />
|
||||
<TerminalLine
|
||||
key={`${log.rawTimestamp ?? ""}-${index}`}
|
||||
log={log}
|
||||
noTimestamp
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{optionalErrors.length > 0 ? (
|
||||
optionalErrors.map((log: LogLine, index: number) => (
|
||||
<TerminalLine key={`extra-${index}`} log={log} noTimestamp />
|
||||
<TerminalLine
|
||||
key={`extra-${log.rawTimestamp ?? ""}-${index}`}
|
||||
log={log}
|
||||
noTimestamp
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex justify-center items-center h-full text-muted-foreground">
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
Copy,
|
||||
Loader2,
|
||||
RefreshCcw,
|
||||
RocketIcon,
|
||||
Settings,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -25,6 +28,7 @@ import {
|
||||
import { api, type RouterOutputs } from "@/utils/api";
|
||||
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
|
||||
import { CancelQueues } from "./cancel-queues";
|
||||
import { ClearDeployments } from "./clear-deployments";
|
||||
import { KillBuild } from "./kill-build";
|
||||
import { RefreshToken } from "./refresh-token";
|
||||
import { ShowDeployment } from "./show-deployment";
|
||||
@@ -59,7 +63,7 @@ export const ShowDeployments = ({
|
||||
const [activeLog, setActiveLog] = useState<
|
||||
RouterOutputs["deployment"]["all"][number] | null
|
||||
>(null);
|
||||
const { data: deployments, isLoading: isLoadingDeployments } =
|
||||
const { data: deployments, isPending: isLoadingDeployments } =
|
||||
api.deployment.allByType.useQuery(
|
||||
{
|
||||
id,
|
||||
@@ -73,19 +77,21 @@ export const ShowDeployments = ({
|
||||
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
|
||||
const { mutateAsync: rollback, isLoading: isRollingBack } =
|
||||
const { mutateAsync: rollback, isPending: isRollingBack } =
|
||||
api.rollback.rollback.useMutation();
|
||||
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
|
||||
const { mutateAsync: killProcess, isPending: isKillingProcess } =
|
||||
api.deployment.killProcess.useMutation();
|
||||
const { mutateAsync: removeDeployment, isPending: isRemovingDeployment } =
|
||||
api.deployment.removeDeployment.useMutation();
|
||||
|
||||
// Cancel deployment mutations
|
||||
const {
|
||||
mutateAsync: cancelApplicationDeployment,
|
||||
isLoading: isCancellingApp,
|
||||
isPending: isCancellingApp,
|
||||
} = api.application.cancelDeployment.useMutation();
|
||||
const {
|
||||
mutateAsync: cancelComposeDeployment,
|
||||
isLoading: isCancellingCompose,
|
||||
isPending: isCancellingCompose,
|
||||
} = api.compose.cancelDeployment.useMutation();
|
||||
|
||||
const [url, setUrl] = React.useState("");
|
||||
@@ -93,6 +99,12 @@ export const ShowDeployments = ({
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const webhookUrl = useMemo(
|
||||
() =>
|
||||
`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`,
|
||||
[url, refreshToken, type],
|
||||
);
|
||||
|
||||
const MAX_DESCRIPTION_LENGTH = 200;
|
||||
|
||||
const truncateDescription = (description: string): string => {
|
||||
@@ -144,6 +156,9 @@ export const ShowDeployments = ({
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-row items-center flex-wrap gap-2">
|
||||
{(type === "application" || type === "compose") && (
|
||||
<ClearDeployments id={id} type={type} />
|
||||
)}
|
||||
{(type === "application" || type === "compose") && (
|
||||
<KillBuild id={id} type={type} />
|
||||
)}
|
||||
@@ -217,11 +232,27 @@ export const ShowDeployments = ({
|
||||
<div className="flex flex-row items-center gap-2 flex-wrap">
|
||||
<span>Webhook URL: </span>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span className="break-all text-muted-foreground">
|
||||
{`${url}/api/deploy${
|
||||
type === "compose" ? "/compose" : ""
|
||||
}/${refreshToken}`}
|
||||
</span>
|
||||
<Badge
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Copy webhook URL to clipboard"
|
||||
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer whitespace-normal break-all"
|
||||
variant="outline"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
copy(webhookUrl);
|
||||
toast.success("Copied to clipboard.");
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
copy(webhookUrl);
|
||||
toast.success("Copied to clipboard.");
|
||||
}}
|
||||
>
|
||||
{webhookUrl}
|
||||
<Copy className="h-4 w-4 ml-2" />
|
||||
</Badge>
|
||||
{(type === "application" || type === "compose") && (
|
||||
<RefreshToken id={id} type={type} />
|
||||
)}
|
||||
@@ -252,6 +283,8 @@ export const ShowDeployments = ({
|
||||
const isExpanded = expandedDescriptions.has(
|
||||
deployment.deploymentId,
|
||||
);
|
||||
const canDelete =
|
||||
deployment.status === "done" || deployment.status === "error";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -370,6 +403,33 @@ export const ShowDeployments = ({
|
||||
View
|
||||
</Button>
|
||||
|
||||
{canDelete && (
|
||||
<DialogAction
|
||||
title="Delete Deployment"
|
||||
description="Are you sure you want to delete this deployment? This action cannot be undone."
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await removeDeployment({
|
||||
deploymentId: deployment.deploymentId,
|
||||
});
|
||||
toast.success("Deployment deleted successfully");
|
||||
} catch (error) {
|
||||
toast.error("Error deleting deployment");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
isLoading={isRemovingDeployment}
|
||||
>
|
||||
Delete
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
|
||||
{deployment?.rollback &&
|
||||
deployment.status === "done" &&
|
||||
type === "application" && (
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
PenBoxIcon,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { RouterOutputs } from "@/utils/api";
|
||||
import type { ValidationStates } from "./show-domains";
|
||||
import { AddDomain } from "./handle-domain";
|
||||
import { DnsHelperModal } from "./dns-helper-modal";
|
||||
|
||||
export type Domain =
|
||||
| RouterOutputs["domain"]["byApplicationId"][0]
|
||||
| RouterOutputs["domain"]["byComposeId"][0];
|
||||
|
||||
interface ColumnsProps {
|
||||
id: string;
|
||||
type: "application" | "compose";
|
||||
validationStates: ValidationStates;
|
||||
handleValidateDomain: (host: string) => Promise<void>;
|
||||
handleDeleteDomain: (domainId: string) => Promise<void>;
|
||||
isDeleting: boolean;
|
||||
serverIp?: string;
|
||||
canCreateDomain: boolean;
|
||||
canDeleteDomain: boolean;
|
||||
}
|
||||
|
||||
export const createColumns = ({
|
||||
id,
|
||||
type,
|
||||
validationStates,
|
||||
handleValidateDomain,
|
||||
handleDeleteDomain,
|
||||
isDeleting,
|
||||
serverIp,
|
||||
canCreateDomain,
|
||||
canDeleteDomain,
|
||||
}: ColumnsProps): ColumnDef<Domain>[] => [
|
||||
...(type === "compose"
|
||||
? [
|
||||
{
|
||||
accessorKey: "serviceName",
|
||||
header: "Service",
|
||||
cell: ({ row }: { row: { getValue: (key: string) => unknown } }) => {
|
||||
const serviceName = row.getValue("serviceName") as string | null;
|
||||
if (!serviceName) return null;
|
||||
return (
|
||||
<Badge variant="outline">
|
||||
<Server className="size-3 mr-1" />
|
||||
{serviceName}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
} satisfies ColumnDef<Domain>,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
accessorKey: "host",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Host
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const domain = row.original;
|
||||
return (
|
||||
<Link
|
||||
className="flex items-center gap-2 font-medium hover:underline"
|
||||
target="_blank"
|
||||
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
||||
>
|
||||
{domain.host}
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "path",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Path
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const path = row.getValue("path") as string;
|
||||
return <div className="font-mono text-sm">{path || "/"}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "port",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Port
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const port = row.getValue("port") as number;
|
||||
return <Badge variant="secondary">{port}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "customEntrypoint",
|
||||
header: "Entrypoint",
|
||||
cell: ({ row }) => {
|
||||
const entrypoint = row.getValue("customEntrypoint") as string | null;
|
||||
if (!entrypoint) return <span className="text-muted-foreground">-</span>;
|
||||
return <div className="font-mono text-sm">{entrypoint}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "https",
|
||||
header: "Protocol",
|
||||
cell: ({ row }) => {
|
||||
const https = row.getValue("https") as boolean;
|
||||
return (
|
||||
<Badge variant={https ? "outline" : "secondary"}>
|
||||
{https ? "HTTPS" : "HTTP"}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "certificate",
|
||||
header: "Certificate",
|
||||
cell: ({ row }) => {
|
||||
const domain = row.original;
|
||||
const validationState = validationStates[domain.host];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{domain.certificateType && (
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{domain.certificateType}
|
||||
</Badge>
|
||||
)}
|
||||
{!domain.host.includes("traefik.me") && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
validationState?.isValid
|
||||
? "bg-green-500/10 text-green-500 cursor-pointer"
|
||||
: validationState?.error
|
||||
? "bg-red-500/10 text-red-500 cursor-pointer"
|
||||
: "bg-yellow-500/10 text-yellow-500 cursor-pointer"
|
||||
}
|
||||
onClick={() => handleValidateDomain(domain.host)}
|
||||
>
|
||||
{validationState?.isLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-3 mr-1 animate-spin" />
|
||||
Checking...
|
||||
</>
|
||||
) : validationState?.isValid ? (
|
||||
<>
|
||||
<CheckCircle2 className="size-3 mr-1" />
|
||||
{validationState.message && validationState.cdnProvider
|
||||
? `${validationState.cdnProvider}`
|
||||
: "Valid"}
|
||||
</>
|
||||
) : validationState?.error ? (
|
||||
<>
|
||||
<XCircle className="size-3 mr-1" />
|
||||
Invalid
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="size-3 mr-1" />
|
||||
Validate
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
{validationState?.error ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-medium text-red-500">Error:</p>
|
||||
<p>{validationState.error}</p>
|
||||
</div>
|
||||
) : (
|
||||
"Click to validate DNS configuration"
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Created
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const createdAt = row.getValue("createdAt") as string;
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{new Date(createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const domain = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{!domain.host.includes("traefik.me") && (
|
||||
<DnsHelperModal
|
||||
domain={{
|
||||
host: domain.host,
|
||||
https: domain.https,
|
||||
path: domain.path || undefined,
|
||||
}}
|
||||
serverIp={serverIp}
|
||||
/>
|
||||
)}
|
||||
{canCreateDomain && (
|
||||
<AddDomain id={id} type={type} domainId={domain.domainId}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10 h-8 w-8"
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
</AddDomain>
|
||||
)}
|
||||
{canDeleteDomain && (
|
||||
<DialogAction
|
||||
title="Delete Domain"
|
||||
description="Are you sure you want to delete this domain?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await handleDeleteDomain(domain.domainId);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 h-8 w-8"
|
||||
isLoading={isDeleting}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,11 +1,12 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { DatabaseZap, Dices, RefreshCw, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -61,11 +62,14 @@ export const domain = z
|
||||
.min(1, { message: "Port must be at least 1" })
|
||||
.max(65535, { message: "Port must be 65535 or below" })
|
||||
.optional(),
|
||||
useCustomEntrypoint: z.boolean(),
|
||||
customEntrypoint: z.string().optional(),
|
||||
https: z.boolean().optional(),
|
||||
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
||||
customCertResolver: z.string().optional(),
|
||||
serviceName: z.string().optional(),
|
||||
domainType: z.enum(["application", "compose", "preview"]).optional(),
|
||||
middlewares: z.array(z.string()).optional(),
|
||||
})
|
||||
.superRefine((input, ctx) => {
|
||||
if (input.https && !input.certificateType) {
|
||||
@@ -114,6 +118,14 @@ export const domain = z
|
||||
message: "Internal path must start with '/'",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.useCustomEntrypoint && !input.customEntrypoint) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["customEntrypoint"],
|
||||
message: "Custom entry point must be specified",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type Domain = z.infer<typeof domain>;
|
||||
@@ -159,11 +171,11 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync, isError, error, isLoading } = domainId
|
||||
const { mutateAsync, isError, error, isPending } = domainId
|
||||
? api.domain.update.useMutation()
|
||||
: api.domain.create.useMutation();
|
||||
|
||||
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
|
||||
const { mutateAsync: generateDomain, isPending: isLoadingGenerate } =
|
||||
api.domain.generateDomain.useMutation();
|
||||
|
||||
const { data: canGenerateTraefikMeDomains } =
|
||||
@@ -196,16 +208,20 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
internalPath: undefined,
|
||||
stripPath: false,
|
||||
port: undefined,
|
||||
useCustomEntrypoint: false,
|
||||
customEntrypoint: undefined,
|
||||
https: false,
|
||||
certificateType: undefined,
|
||||
customCertResolver: undefined,
|
||||
serviceName: undefined,
|
||||
domainType: type,
|
||||
middlewares: [],
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const certificateType = form.watch("certificateType");
|
||||
const useCustomEntrypoint = form.watch("useCustomEntrypoint");
|
||||
const https = form.watch("https");
|
||||
const domainType = form.watch("domainType");
|
||||
const host = form.watch("host");
|
||||
@@ -220,10 +236,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
internalPath: data?.internalPath || undefined,
|
||||
stripPath: data?.stripPath || false,
|
||||
port: data?.port || undefined,
|
||||
useCustomEntrypoint: !!data.customEntrypoint,
|
||||
customEntrypoint: data.customEntrypoint || undefined,
|
||||
certificateType: data?.certificateType || undefined,
|
||||
customCertResolver: data?.customCertResolver || undefined,
|
||||
serviceName: data?.serviceName || undefined,
|
||||
domainType: data?.domainType || type,
|
||||
middlewares: data?.middlewares || [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -234,13 +253,16 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
internalPath: undefined,
|
||||
stripPath: false,
|
||||
port: undefined,
|
||||
useCustomEntrypoint: false,
|
||||
customEntrypoint: undefined,
|
||||
https: false,
|
||||
certificateType: undefined,
|
||||
customCertResolver: undefined,
|
||||
domainType: type,
|
||||
middlewares: [],
|
||||
});
|
||||
}
|
||||
}, [form, data, isLoading, domainId]);
|
||||
}, [form, data, isPending, domainId]);
|
||||
|
||||
// Separate effect for handling custom cert resolver validation
|
||||
useEffect(() => {
|
||||
@@ -268,6 +290,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
composeId: id,
|
||||
}),
|
||||
...data,
|
||||
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success(dictionary.success);
|
||||
@@ -635,6 +658,55 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="useCustomEntrypoint"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Custom Entrypoint</FormLabel>
|
||||
<FormDescription>
|
||||
Use custom entrypoint for domina
|
||||
<br />
|
||||
"web" and/or "websecure" is used by default.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
if (!checked) {
|
||||
form.setValue("customEntrypoint", undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{useCustomEntrypoint && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customEntrypoint"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Entrypoint Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter entrypoint name manually"
|
||||
{...field}
|
||||
className="w-full"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="https"
|
||||
@@ -725,12 +797,94 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="middlewares"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>Middlewares</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[300px]">
|
||||
<p>
|
||||
Add Traefik middleware references. Middlewares
|
||||
must be defined in your Traefik configuration.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{field.value?.map((name, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{name}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
onClick={() => {
|
||||
const newMiddlewares = [...(field.value || [])];
|
||||
newMiddlewares.splice(index, 1);
|
||||
form.setValue("middlewares", newMiddlewares);
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="e.g., rate-limit@file, auth@file"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget;
|
||||
const value = input.value.trim();
|
||||
if (value && !field.value?.includes(value)) {
|
||||
form.setValue("middlewares", [
|
||||
...(field.value || []),
|
||||
value,
|
||||
]);
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder="e.g., rate-limit@file, auth@file"]',
|
||||
) as HTMLInputElement;
|
||||
const value = input.value.trim();
|
||||
if (value && !field.value?.includes(value)) {
|
||||
form.setValue("middlewares", [
|
||||
...(field.value || []),
|
||||
value,
|
||||
]);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button isLoading={isLoading} form="hook-form" type="submit">
|
||||
<Button isLoading={isPending} form="hook-form" type="submit">
|
||||
{dictionary.submit}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import {
|
||||
type ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
LayoutGrid,
|
||||
LayoutList,
|
||||
Loader2,
|
||||
PenBoxIcon,
|
||||
RefreshCw,
|
||||
@@ -23,6 +37,21 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -30,6 +59,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { createColumns } from "./columns";
|
||||
import { DnsHelperModal } from "./dns-helper-modal";
|
||||
import { AddDomain } from "./handle-domain";
|
||||
|
||||
@@ -50,6 +80,9 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowDomains = ({ id, type }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canCreateDomain = permissions?.domain.create ?? false;
|
||||
const canDeleteDomain = permissions?.domain.delete ?? false;
|
||||
const { data: application } =
|
||||
type === "application"
|
||||
? api.application.one.useQuery(
|
||||
@@ -71,6 +104,19 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
const [validationStates, setValidationStates] = useState<ValidationStates>(
|
||||
{},
|
||||
);
|
||||
const [viewMode, setViewMode] = useState<"grid" | "table">(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return (
|
||||
(localStorage.getItem("domains-view-mode") as "grid" | "table") ??
|
||||
"grid"
|
||||
);
|
||||
}
|
||||
return "grid";
|
||||
});
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
|
||||
const {
|
||||
@@ -97,9 +143,19 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
|
||||
const { mutateAsync: validateDomain } =
|
||||
api.domain.validateDomain.useMutation();
|
||||
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
|
||||
const { mutateAsync: deleteDomain, isPending: isRemoving } =
|
||||
api.domain.delete.useMutation();
|
||||
|
||||
const handleDeleteDomain = async (domainId: string) => {
|
||||
try {
|
||||
await deleteDomain({ domainId });
|
||||
refetch();
|
||||
toast.success("Domain deleted successfully");
|
||||
} catch {
|
||||
toast.error("Error deleting domain");
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidateDomain = async (host: string) => {
|
||||
setValidationStates((prev) => ({
|
||||
...prev,
|
||||
@@ -137,6 +193,37 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const columns = createColumns({
|
||||
id,
|
||||
type,
|
||||
validationStates,
|
||||
handleValidateDomain,
|
||||
handleDeleteDomain,
|
||||
isDeleting: isRemoving,
|
||||
serverIp: application?.server?.ipAddress?.toString() || ip?.toString(),
|
||||
canCreateDomain,
|
||||
canDeleteDomain,
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
data: data ?? [],
|
||||
columns,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
@@ -148,13 +235,32 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-4 flex-wrap">
|
||||
<div className="flex flex-row gap-2 flex-wrap">
|
||||
{data && data?.length > 0 && (
|
||||
<AddDomain id={id} type={type}>
|
||||
<Button>
|
||||
<GlobeIcon className="size-4" /> Add Domain
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const next = viewMode === "grid" ? "table" : "grid";
|
||||
localStorage.setItem("domains-view-mode", next);
|
||||
setViewMode(next);
|
||||
}}
|
||||
>
|
||||
{viewMode === "grid" ? (
|
||||
<LayoutList className="size-4" />
|
||||
) : (
|
||||
<LayoutGrid className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</AddDomain>
|
||||
{canCreateDomain && (
|
||||
<AddDomain id={id} type={type}>
|
||||
<Button>
|
||||
<GlobeIcon className="size-4" /> Add Domain
|
||||
</Button>
|
||||
</AddDomain>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -173,13 +279,131 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
To access the application it is required to set at least 1
|
||||
domain
|
||||
</span>
|
||||
<div className="flex flex-row gap-4 flex-wrap">
|
||||
<AddDomain id={id} type={type}>
|
||||
<Button>
|
||||
<GlobeIcon className="size-4" /> Add Domain
|
||||
</Button>
|
||||
</AddDomain>
|
||||
{canCreateDomain && (
|
||||
<div className="flex flex-row gap-4 flex-wrap">
|
||||
<AddDomain id={id} type={type}>
|
||||
<Button>
|
||||
<GlobeIcon className="size-4" /> Add Domain
|
||||
</Button>
|
||||
</AddDomain>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : viewMode === "table" ? (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex items-center gap-2 max-sm:flex-wrap">
|
||||
<Input
|
||||
placeholder="Filter by host..."
|
||||
value={
|
||||
(table.getColumn("host")?.getFilterValue() as string) ?? ""
|
||||
}
|
||||
onChange={(event) =>
|
||||
table.getColumn("host")?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="md:max-w-sm"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="sm:ml-auto max-sm:w-full"
|
||||
>
|
||||
Columns <ChevronDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(!!value)
|
||||
}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table?.getRowModel()?.rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{data && data?.length > 0 && (
|
||||
<div className="flex items-center justify-end space-x-2 py-4">
|
||||
<div className="space-x-2 flex flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
|
||||
@@ -214,47 +438,51 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<AddDomain
|
||||
id={id}
|
||||
type={type}
|
||||
domainId={item.domainId}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10"
|
||||
{canCreateDomain && (
|
||||
<AddDomain
|
||||
id={id}
|
||||
type={type}
|
||||
domainId={item.domainId}
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
</AddDomain>
|
||||
<DialogAction
|
||||
title="Delete Domain"
|
||||
description="Are you sure you want to delete this domain?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteDomain({
|
||||
domainId: item.domainId,
|
||||
})
|
||||
.then((_data) => {
|
||||
refetch();
|
||||
toast.success(
|
||||
"Domain deleted successfully",
|
||||
);
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10"
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
</AddDomain>
|
||||
)}
|
||||
{canDeleteDomain && (
|
||||
<DialogAction
|
||||
title="Delete Domain"
|
||||
description="Are you sure you want to delete this domain?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteDomain({
|
||||
domainId: item.domainId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting domain");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
.then((_data) => {
|
||||
refetch();
|
||||
toast.success(
|
||||
"Domain deleted successfully",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting domain");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full break-all">
|
||||
@@ -332,6 +560,22 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{item.middlewares?.map((middleware, index) => (
|
||||
<TooltipProvider key={`${middleware}-${index}`}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="secondary">
|
||||
<InfoIcon className="size-3 mr-1" />
|
||||
Middleware: {middleware}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Traefik middleware reference</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
))}
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
||||
import { type CSSProperties, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -36,16 +36,19 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canWrite = permissions?.envVars.write ?? false;
|
||||
const queryMap = {
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
compose: () =>
|
||||
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
compose: () =>
|
||||
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -53,14 +56,15 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
||||
|
||||
const mutationMap = {
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
compose: () => api.compose.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
compose: () => api.compose.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
};
|
||||
const { mutateAsync, isLoading } = mutationMap[type]
|
||||
const { mutateAsync, isPending } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.mongo.update.useMutation();
|
||||
|
||||
@@ -85,12 +89,13 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
|
||||
const onSubmit = async (formData: EnvironmentSchema) => {
|
||||
mutateAsync({
|
||||
composeId: id || "",
|
||||
libsqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
mysqlId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
composeId: id || "",
|
||||
env: formData.environment,
|
||||
})
|
||||
.then(async () => {
|
||||
@@ -111,7 +116,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
||||
e.preventDefault();
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
@@ -121,7 +126,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [form, onSubmit, isLoading]);
|
||||
}, [form, onSubmit, isPending]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
@@ -185,25 +190,27 @@ PORT=3000
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{hasChanges && (
|
||||
{canWrite && (
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{hasChanges && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
isLoading={isPending}
|
||||
className="w-fit"
|
||||
type="submit"
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Cancel
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
className="w-fit"
|
||||
type="submit"
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -31,7 +31,9 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
const { mutateAsync, isLoading } =
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canWrite = permissions?.envVars.write ?? false;
|
||||
const { mutateAsync, isPending } =
|
||||
api.application.saveEnvironment.useMutation();
|
||||
|
||||
const { data, refetch } = api.application.one.useQuery(
|
||||
@@ -104,7 +106,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
||||
e.preventDefault();
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
@@ -114,7 +116,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [form, onSubmit, isLoading]);
|
||||
}, [form, onSubmit, isPending]);
|
||||
|
||||
return (
|
||||
<Card className="bg-background px-6 pb-6">
|
||||
@@ -201,27 +203,30 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={!canWrite}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{hasChanges && (
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
{canWrite && (
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{hasChanges && (
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
className="w-fit"
|
||||
type="submit"
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
className="w-fit"
|
||||
type="submit"
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -74,10 +74,10 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
api.bitbucket.bitbucketProviders.useQuery();
|
||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||
|
||||
const { mutateAsync, isLoading: isSavingBitbucketProvider } =
|
||||
const { mutateAsync, isPending: isSavingBitbucketProvider } =
|
||||
api.application.saveBitbucketProvider.useMutation();
|
||||
|
||||
const form = useForm<BitbucketProvider>({
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
buildPath: "/",
|
||||
repository: {
|
||||
@@ -245,13 +245,13 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -263,11 +263,15 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!bitbucketId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a Bitbucket account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
@@ -329,7 +333,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status === "loading" && fetchStatus === "fetching"
|
||||
{status === "pending" && fetchStatus === "fetching"
|
||||
? "Loading...."
|
||||
: field.value
|
||||
? branches?.find(
|
||||
@@ -346,7 +350,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
placeholder="Search branch..."
|
||||
className="h-9"
|
||||
/>
|
||||
{status === "loading" && fetchStatus === "fetching" && (
|
||||
{status === "pending" && fetchStatus === "fetching" && (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Loading Branches....
|
||||
</span>
|
||||
@@ -412,10 +416,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -24,10 +24,10 @@ interface Props {
|
||||
export const SaveDragNDrop = ({ applicationId }: Props) => {
|
||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||
|
||||
const { mutateAsync, isLoading } =
|
||||
const { mutateAsync, isPending } =
|
||||
api.application.dropDeployment.useMutation();
|
||||
|
||||
const form = useForm<UploadFile>({
|
||||
const form = useForm({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(uploadFileSchema),
|
||||
});
|
||||
@@ -129,8 +129,8 @@ export const SaveDragNDrop = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-fit"
|
||||
isLoading={isLoading}
|
||||
disabled={!zip || isLoading}
|
||||
isLoading={isPending}
|
||||
disabled={!zip || isPending}
|
||||
>
|
||||
Deploy{" "}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
@@ -58,10 +58,10 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync, isLoading } =
|
||||
const { mutateAsync, isPending } =
|
||||
api.application.saveGitProvider.useMutation();
|
||||
|
||||
const form = useForm<GitProvider>({
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
branch: "",
|
||||
buildPath: "/",
|
||||
@@ -228,10 +228,8 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[300px]">
|
||||
<p>
|
||||
@@ -317,7 +315,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end">
|
||||
<Button type="submit" className="w-fit" isLoading={isLoading}>
|
||||
<Button type="submit" className="w-fit" isLoading={isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
@@ -88,10 +88,10 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
|
||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||
|
||||
const { mutateAsync, isLoading: isSavingGiteaProvider } =
|
||||
const { mutateAsync, isPending: isSavingGiteaProvider } =
|
||||
api.application.saveGiteaProvider.useMutation();
|
||||
|
||||
const form = useForm<GiteaProvider>({
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
buildPath: "/",
|
||||
repository: {
|
||||
@@ -258,14 +258,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo: GiteaRepository) =>
|
||||
repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -277,11 +277,15 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!giteaId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a Gitea account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
@@ -349,7 +353,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status === "loading" && fetchStatus === "fetching"
|
||||
{status === "pending" && fetchStatus === "fetching"
|
||||
? "Loading...."
|
||||
: field.value
|
||||
? branches?.find(
|
||||
@@ -367,7 +371,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
placeholder="Search branch..."
|
||||
className="h-9"
|
||||
/>
|
||||
{status === "loading" && fetchStatus === "fetching" && (
|
||||
{status === "pending" && fetchStatus === "fetching" && (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Loading Branches....
|
||||
</span>
|
||||
@@ -459,7 +463,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
<X
|
||||
className="size-3 cursor-pointer hover:text-destructive"
|
||||
onClick={() => {
|
||||
const newPaths = [...field.value];
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
field.onChange(newPaths);
|
||||
}}
|
||||
@@ -477,7 +481,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
const input = e.currentTarget;
|
||||
const path = input.value.trim();
|
||||
if (path) {
|
||||
field.onChange([...field.value, path]);
|
||||
field.onChange([...(field.value || []), path]);
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
@@ -494,7 +498,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
) as HTMLInputElement;
|
||||
const path = input.value.trim();
|
||||
if (path) {
|
||||
field.onChange([...field.value, path]);
|
||||
field.onChange([...(field.value || []), path]);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
@@ -72,10 +72,10 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
const { data: githubProviders } = api.github.githubProviders.useQuery();
|
||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||
|
||||
const { mutateAsync, isLoading: isSavingGithubProvider } =
|
||||
const { mutateAsync, isPending: isSavingGithubProvider } =
|
||||
api.application.saveGithubProvider.useMutation();
|
||||
|
||||
const form = useForm<GithubProvider>({
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
buildPath: "/",
|
||||
repository: {
|
||||
@@ -94,7 +94,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
const githubId = form.watch("githubId");
|
||||
const triggerType = form.watch("triggerType");
|
||||
|
||||
const { data: repositories, isLoading: isLoadingRepositories } =
|
||||
const { data: repositories, isPending: isLoadingRepositories } =
|
||||
api.github.getGithubRepositories.useQuery(
|
||||
{
|
||||
githubId,
|
||||
@@ -233,13 +233,13 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -251,11 +251,15 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!githubId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a GitHub account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
@@ -316,7 +320,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status === "loading" && fetchStatus === "fetching"
|
||||
{status === "pending" && fetchStatus === "fetching"
|
||||
? "Loading...."
|
||||
: field.value
|
||||
? branches?.find(
|
||||
@@ -333,7 +337,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
placeholder="Search branch..."
|
||||
className="h-9"
|
||||
/>
|
||||
{status === "loading" && fetchStatus === "fetching" && (
|
||||
{status === "pending" && fetchStatus === "fetching" && (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Loading Branches....
|
||||
</span>
|
||||
@@ -455,7 +459,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
key={`${path}-${index}`}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo } from "react";
|
||||
@@ -74,10 +74,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
|
||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||
|
||||
const { mutateAsync, isLoading: isSavingGitlabProvider } =
|
||||
const { mutateAsync, isPending: isSavingGitlabProvider } =
|
||||
api.application.saveGitlabProvider.useMutation();
|
||||
|
||||
const form = useForm<GitlabProvider>({
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
buildPath: "/",
|
||||
repository: {
|
||||
@@ -254,13 +254,13 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -272,11 +272,15 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!gitlabId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a GitLab account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
@@ -347,7 +351,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status === "loading" && fetchStatus === "fetching"
|
||||
{status === "pending" && fetchStatus === "fetching"
|
||||
? "Loading...."
|
||||
: field.value
|
||||
? branches?.find(
|
||||
@@ -364,7 +368,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
placeholder="Search branch..."
|
||||
className="h-9"
|
||||
/>
|
||||
{status === "loading" && fetchStatus === "fetching" && (
|
||||
{status === "pending" && fetchStatus === "fetching" && (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Loading Branches....
|
||||
</span>
|
||||
@@ -444,7 +448,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
key={`${path}-${index}`}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
|
||||
@@ -36,13 +36,13 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowProviderForm = ({ applicationId }: Props) => {
|
||||
const { data: githubProviders, isLoading: isLoadingGithub } =
|
||||
const { data: githubProviders, isPending: isLoadingGithub } =
|
||||
api.github.githubProviders.useQuery();
|
||||
const { data: gitlabProviders, isLoading: isLoadingGitlab } =
|
||||
const { data: gitlabProviders, isPending: isLoadingGitlab } =
|
||||
api.gitlab.gitlabProviders.useQuery();
|
||||
const { data: bitbucketProviders, isLoading: isLoadingBitbucket } =
|
||||
const { data: bitbucketProviders, isPending: isLoadingBitbucket } =
|
||||
api.bitbucket.bitbucketProviders.useQuery();
|
||||
const { data: giteaProviders, isLoading: isLoadingGitea } =
|
||||
const { data: giteaProviders, isPending: isLoadingGitea } =
|
||||
api.gitea.giteaProviders.useQuery();
|
||||
|
||||
const { data: application, refetch } = api.application.one.useQuery({
|
||||
|
||||
@@ -30,6 +30,9 @@ interface Props {
|
||||
|
||||
export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
const router = useRouter();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canDeploy = permissions?.deployment.create ?? false;
|
||||
const canUpdateService = permissions?.service.create ?? false;
|
||||
const { data, refetch } = api.application.one.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
@@ -37,14 +40,14 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
const { mutateAsync: update } = api.application.update.useMutation();
|
||||
const { mutateAsync: start, isLoading: isStarting } =
|
||||
const { mutateAsync: start, isPending: isStarting } =
|
||||
api.application.start.useMutation();
|
||||
const { mutateAsync: stop, isLoading: isStopping } =
|
||||
const { mutateAsync: stop, isPending: isStopping } =
|
||||
api.application.stop.useMutation();
|
||||
|
||||
const { mutateAsync: deploy } = api.application.deploy.useMutation();
|
||||
|
||||
const { mutateAsync: reload, isLoading: isReloading } =
|
||||
const { mutateAsync: reload, isPending: isReloading } =
|
||||
api.application.reload.useMutation();
|
||||
|
||||
const { mutateAsync: redeploy } = api.application.redeploy.useMutation();
|
||||
@@ -57,128 +60,135 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
||||
<DialogAction
|
||||
title="Deploy Application"
|
||||
description="Are you sure you want to deploy this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await deploy({
|
||||
applicationId: applicationId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Application deployed successfully");
|
||||
refetch();
|
||||
router.push(
|
||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
|
||||
);
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Deploy Application"
|
||||
description="Are you sure you want to deploy this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await deploy({
|
||||
applicationId: applicationId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deploying application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
.then(() => {
|
||||
toast.success("Application deployed successfully");
|
||||
refetch();
|
||||
router.push(
|
||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deploying application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Downloads the source code and performs a complete build
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<DialogAction
|
||||
title="Reload Application"
|
||||
description="Are you sure you want to reload this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
applicationId: applicationId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Application reloaded successfully");
|
||||
refetch();
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Downloads the source code and performs a complete
|
||||
build
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Reload Application"
|
||||
description="Are you sure you want to reload this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
applicationId: applicationId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
.then(() => {
|
||||
toast.success("Application reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Reload the application without rebuilding it</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<DialogAction
|
||||
title="Rebuild Application"
|
||||
description="Are you sure you want to rebuild this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await redeploy({
|
||||
applicationId: applicationId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Application rebuilt successfully");
|
||||
refetch();
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Reload the application without rebuilding it</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Rebuild Application"
|
||||
description="Are you sure you want to rebuild this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await redeploy({
|
||||
applicationId: applicationId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error rebuilding application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
.then(() => {
|
||||
toast.success("Application rebuilt successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error rebuilding application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Hammer className="size-4 mr-1" />
|
||||
Rebuild
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Only rebuilds the application without downloading new
|
||||
code
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Hammer className="size-4 mr-1" />
|
||||
Rebuild
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Only rebuilds the application without downloading new
|
||||
code
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
{canDeploy && data?.applicationStatus === "idle" ? (
|
||||
<DialogAction
|
||||
title="Start Application"
|
||||
description="Are you sure you want to start this application?"
|
||||
@@ -219,7 +229,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
) : canDeploy ? (
|
||||
<DialogAction
|
||||
title="Stop Application"
|
||||
description="Are you sure you want to stop this application?"
|
||||
@@ -256,7 +266,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
) : null}
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
@@ -270,49 +280,53 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
Open Terminal
|
||||
</Button>
|
||||
</DockerTerminalModal>
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Autodeploy</span>
|
||||
<Switch
|
||||
aria-label="Toggle autodeploy"
|
||||
checked={data?.autoDeploy || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
applicationId,
|
||||
autoDeploy: enabled,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Auto Deploy Updated");
|
||||
await refetch();
|
||||
{canUpdateService && (
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Autodeploy</span>
|
||||
<Switch
|
||||
aria-label="Toggle autodeploy"
|
||||
checked={data?.autoDeploy || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
applicationId,
|
||||
autoDeploy: enabled,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Auto Deploy");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
.then(async () => {
|
||||
toast.success("Auto Deploy Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Auto Deploy");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Clean Cache</span>
|
||||
<Switch
|
||||
aria-label="Toggle clean cache"
|
||||
checked={data?.cleanCache || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
applicationId,
|
||||
cleanCache: enabled,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Clean Cache Updated");
|
||||
await refetch();
|
||||
{canUpdateService && (
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Clean Cache</span>
|
||||
<Switch
|
||||
aria-label="Toggle clean cache"
|
||||
checked={data?.cleanCache || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
applicationId,
|
||||
cleanCache: enabled,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Clean Cache");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
.then(async () => {
|
||||
toast.success("Clean Cache Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Clean Cache");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ShowProviderForm applicationId={applicationId} />
|
||||
|
||||
@@ -34,6 +34,7 @@ export const DockerLogs = dynamic(
|
||||
export const badgeStateColor = (state: string) => {
|
||||
switch (state) {
|
||||
case "running":
|
||||
case "ready":
|
||||
return "green";
|
||||
case "exited":
|
||||
case "shutdown":
|
||||
@@ -55,7 +56,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
const [containerId, setContainerId] = useState<string | undefined>();
|
||||
const [option, setOption] = useState<"swarm" | "native">("native");
|
||||
|
||||
const { data: services, isLoading: servicesLoading } =
|
||||
const { data: services, isPending: servicesLoading } =
|
||||
api.docker.getServiceContainersByAppName.useQuery(
|
||||
{
|
||||
appName,
|
||||
@@ -66,7 +67,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
},
|
||||
);
|
||||
|
||||
const { data: containers, isLoading: containersLoading } =
|
||||
const { data: containers, isPending: containersLoading } =
|
||||
api.docker.getContainersByAppNameMatch.useQuery(
|
||||
{
|
||||
appName,
|
||||
@@ -90,7 +91,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
}, [option, services, containers]);
|
||||
|
||||
const isLoading = option === "native" ? containersLoading : servicesLoading;
|
||||
const containersLenght =
|
||||
const containersLength =
|
||||
option === "native" ? containers?.length : services?.length;
|
||||
|
||||
return (
|
||||
@@ -142,6 +143,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.status ? ` ${container.status}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
@@ -157,15 +159,25 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.currentState
|
||||
? ` ${container.currentState}`
|
||||
: ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<SelectLabel>Containers ({containersLenght})</SelectLabel>
|
||||
<SelectLabel>Containers ({containersLength})</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{option === "swarm" &&
|
||||
services?.find((c) => c.containerId === containerId)?.error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-3 py-2 text-sm text-destructive">
|
||||
<span className="font-medium">Error: </span>
|
||||
{services?.find((c) => c.containerId === containerId)?.error}
|
||||
</div>
|
||||
)}
|
||||
<DockerLogs
|
||||
serverId={serverId || ""}
|
||||
containerId={containerId || "select-a-container"}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { FilePlus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface Props {
|
||||
folderPath: string;
|
||||
onCreate: (filename: string, content: string) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
alwaysVisible?: boolean;
|
||||
}
|
||||
|
||||
export const CreateFileDialog = ({
|
||||
folderPath,
|
||||
onCreate,
|
||||
onOpenChange,
|
||||
alwaysVisible = false,
|
||||
}: Props) => {
|
||||
const [filename, setFilename] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!filename.trim()) return;
|
||||
onCreate(filename.trim(), content);
|
||||
setFilename("");
|
||||
setContent("");
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
className={`h-6 w-6 ${alwaysVisible ? "" : "opacity-0 group-hover:opacity-100"}`}
|
||||
title="Create file"
|
||||
>
|
||||
<FilePlus className="h-3 w-3" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleCreate();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create file</DialogTitle>
|
||||
<DialogDescription>
|
||||
{folderPath ? `New file in ${folderPath}/` : "New file in root"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="filename">Filename</Label>
|
||||
<Input
|
||||
id="filename"
|
||||
placeholder="e.g. .env.example"
|
||||
value={filename}
|
||||
onChange={(e) => setFilename(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Content</Label>
|
||||
<div className="h-[200px] rounded-md border">
|
||||
<CodeEditor
|
||||
value={content}
|
||||
onChange={(v) => setContent(v ?? "")}
|
||||
className="h-full"
|
||||
wrapperClassName="h-[200px]"
|
||||
lineWrapping
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button type="submit" disabled={!filename.trim()}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Loader2, Pencil } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
patchId: string;
|
||||
entityId: string;
|
||||
type: "application" | "compose";
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const EditPatchDialog = ({
|
||||
patchId,
|
||||
entityId,
|
||||
type,
|
||||
onSuccess,
|
||||
}: Props) => {
|
||||
const { data: patch, isPending: isPatchLoading } = api.patch.one.useQuery(
|
||||
{ patchId },
|
||||
{ enabled: !!patchId },
|
||||
);
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (patch) {
|
||||
setContent(patch.content);
|
||||
}
|
||||
}, [patch]);
|
||||
|
||||
const utils = api.useUtils();
|
||||
const updatePatch = api.patch.update.useMutation();
|
||||
|
||||
const handleSave = () => {
|
||||
updatePatch
|
||||
.mutateAsync({ patchId, content })
|
||||
.then(() => {
|
||||
toast.success("Patch saved");
|
||||
utils.patch.byEntityId.invalidate({ id: entityId, type });
|
||||
onSuccess?.();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" title="Edit patch">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-4xl max-h-[85vh] flex flex-col p-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-4">
|
||||
<DialogTitle>Edit Patch</DialogTitle>
|
||||
<DialogDescription>
|
||||
{patch ? `Editing: ${patch.filePath}` : "Loading patch..."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isPatchLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center px-6 py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 px-6 overflow-hidden flex flex-col">
|
||||
<CodeEditor
|
||||
value={content}
|
||||
onChange={(value) => setContent(value ?? "")}
|
||||
className="h-[400px] w-full"
|
||||
wrapperClassName="h-[400px]"
|
||||
lineWrapping
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="px-6 ">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button onClick={handleSave} isLoading={updatePatch.isPending}>
|
||||
{updatePatch.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./patch-editor";
|
||||
export * from "./show-patches";
|
||||
@@ -0,0 +1,368 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronRight,
|
||||
File,
|
||||
Folder,
|
||||
Loader2,
|
||||
Save,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { api } from "@/utils/api";
|
||||
import { CreateFileDialog } from "./create-file-dialog";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: "application" | "compose";
|
||||
repoPath: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type DirectoryEntry = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "file" | "directory";
|
||||
children?: DirectoryEntry[];
|
||||
};
|
||||
|
||||
export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => {
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [fileContent, setFileContent] = useState<string>("");
|
||||
const [createFolderPath, setCreateFolderPath] = useState<string | null>(null);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const utils = api.useUtils();
|
||||
const { data: directories, isPending: isDirLoading } =
|
||||
api.patch.readRepoDirectories.useQuery(
|
||||
{ id: id, type, repoPath },
|
||||
{ enabled: !!repoPath },
|
||||
);
|
||||
|
||||
const { data: patches } = api.patch.byEntityId.useQuery(
|
||||
{ id, type },
|
||||
{ enabled: !!id },
|
||||
);
|
||||
|
||||
const { mutateAsync: saveAsPatch, isPending: isSavingPatch } =
|
||||
api.patch.saveFileAsPatch.useMutation();
|
||||
|
||||
const { mutateAsync: markForDeletion, isPending: isMarkingDeletion } =
|
||||
api.patch.markFileForDeletion.useMutation();
|
||||
|
||||
const updatePatch = api.patch.update.useMutation();
|
||||
|
||||
const { data: fileData, isFetching: isFileLoading } =
|
||||
api.patch.readRepoFile.useQuery(
|
||||
{
|
||||
id,
|
||||
type,
|
||||
filePath: selectedFile || "",
|
||||
},
|
||||
{
|
||||
enabled: !!selectedFile,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (fileData !== undefined) {
|
||||
setFileContent(fileData);
|
||||
}
|
||||
}, [fileData]);
|
||||
|
||||
const handleFileSelect = (filePath: string) => {
|
||||
setSelectedFile(filePath);
|
||||
};
|
||||
|
||||
const toggleFolder = (path: string) => {
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!selectedFile) return;
|
||||
saveAsPatch({
|
||||
id,
|
||||
type,
|
||||
filePath: selectedFile,
|
||||
content: fileContent,
|
||||
patchType: "update",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Patch saved");
|
||||
utils.patch.byEntityId.invalidate({ id, type });
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Failed to save patch");
|
||||
});
|
||||
};
|
||||
|
||||
const handleMarkForDeletion = () => {
|
||||
if (!selectedFile) return;
|
||||
markForDeletion({ id, type, filePath: selectedFile })
|
||||
.then(() => {
|
||||
toast.success("File marked for deletion");
|
||||
utils.patch.byEntityId.invalidate({ id, type });
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Failed to mark file for deletion");
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateFile = useCallback(
|
||||
(folderPath: string, filename: string, content: string) => {
|
||||
const filePath = folderPath ? `${folderPath}/${filename}` : filename;
|
||||
saveAsPatch({
|
||||
id,
|
||||
type,
|
||||
filePath,
|
||||
content,
|
||||
patchType: "create",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("File created");
|
||||
utils.patch.byEntityId.invalidate({ id, type });
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Failed to create file");
|
||||
});
|
||||
},
|
||||
[id, type, saveAsPatch, utils],
|
||||
);
|
||||
|
||||
const selectedFilePatch = patches?.find(
|
||||
(p) => p.filePath === selectedFile && p.type === "delete",
|
||||
);
|
||||
|
||||
const handleUnmarkDeletion = () => {
|
||||
if (!selectedFilePatch) return;
|
||||
updatePatch
|
||||
.mutateAsync({
|
||||
patchId: selectedFilePatch.patchId,
|
||||
type: "update",
|
||||
content: fileData || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Deletion unmarked");
|
||||
utils.patch.byEntityId.invalidate({ id, type });
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Failed to unmark deletion");
|
||||
});
|
||||
};
|
||||
|
||||
const hasChanges = fileData !== undefined && fileContent !== fileData;
|
||||
|
||||
const renderTree = useCallback(
|
||||
(entries: DirectoryEntry[], depth = 0) => {
|
||||
return entries
|
||||
.sort((a, b) => {
|
||||
// Directories first, then alphabetically
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "directory" ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map((entry) => {
|
||||
const isExpanded = expandedFolders.has(entry.path);
|
||||
const isSelected = selectedFile === entry.path;
|
||||
|
||||
if (entry.type === "directory") {
|
||||
return (
|
||||
<div key={entry.path}>
|
||||
<div className="group flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFolder(entry.path)}
|
||||
className={
|
||||
"flex-1 flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors text-left min-w-0"
|
||||
}
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-4 w-4 shrink-0 transition-transform ${
|
||||
isExpanded ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
<Folder className="h-4 w-4 shrink-0 text-blue-500" />
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</button>
|
||||
<CreateFileDialog
|
||||
folderPath={entry.path}
|
||||
onCreate={(filename, content) =>
|
||||
handleCreateFile(entry.path, filename, content)
|
||||
}
|
||||
onOpenChange={(open) =>
|
||||
setCreateFolderPath(open ? entry.path : null)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{isExpanded && entry.children && (
|
||||
<div>{renderTree(entry.children, depth + 1)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isMarkedForDeletion = patches?.some(
|
||||
(p) => p.filePath === entry.path && p.type === "delete",
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={entry.path}
|
||||
onClick={() => handleFileSelect(entry.path)}
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors ${
|
||||
isSelected ? "bg-muted" : ""
|
||||
} ${isMarkedForDeletion ? "text-destructive" : ""}`}
|
||||
style={{ paddingLeft: `${depth * 12 + 28}px` }}
|
||||
>
|
||||
<File className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{entry.name}</span>
|
||||
{isMarkedForDeletion && (
|
||||
<Trash2 className="h-3 w-3 shrink-0 text-destructive ml-auto" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
},
|
||||
[expandedFolders, selectedFile, patches, handleCreateFile],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="bg-background overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<CardTitle>Edit File</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedFile
|
||||
? `Editing: ${selectedFile}`
|
||||
: "Select a file from the tree to edit"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{selectedFile && (
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedFilePatch ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleUnmarkDeletion}
|
||||
disabled={updatePatch.isPending}
|
||||
>
|
||||
{updatePatch.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Unmark deletion
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleMarkForDeletion}
|
||||
disabled={isMarkingDeletion}
|
||||
>
|
||||
{isMarkingDeletion && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Mark for deletion
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSavingPatch || !hasChanges}
|
||||
>
|
||||
{isSavingPatch && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Patch
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="grid grid-cols-[250px_1fr] border-t h-[600px]">
|
||||
<div className="border-r h-full overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2 space-y-1">
|
||||
<div className="group flex items-center gap-2 px-2 py-1.5 mb-1">
|
||||
<CreateFileDialog
|
||||
folderPath=""
|
||||
alwaysVisible
|
||||
onCreate={(filename, content) =>
|
||||
handleCreateFile("", filename, content)
|
||||
}
|
||||
onOpenChange={(open) =>
|
||||
setCreateFolderPath(open ? "" : null)
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
New file in root
|
||||
</span>
|
||||
</div>
|
||||
{isDirLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : directories ? (
|
||||
renderTree(directories)
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground p-4">
|
||||
No files found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div className="h-full overflow-hidden relative">
|
||||
{isFileLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : selectedFile ? (
|
||||
<CodeEditor
|
||||
value={fileData || ""}
|
||||
onChange={(value) => setFileContent(value || "")}
|
||||
className="h-full w-full"
|
||||
wrapperClassName="h-full"
|
||||
lineWrapping
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
Select a file to edit
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,225 @@
|
||||
import { File, FilePlus2, Loader2, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { api } from "@/utils/api";
|
||||
import { EditPatchDialog } from "./edit-patch-dialog";
|
||||
import { PatchEditor } from "./patch-editor";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: "application" | "compose";
|
||||
}
|
||||
|
||||
export const ShowPatches = ({ id, type }: Props) => {
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [repoPath, setRepoPath] = useState<string | null>(null);
|
||||
const [isLoadingRepo, setIsLoadingRepo] = useState(false);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data: patches, isPending: isPatchesLoading } =
|
||||
api.patch.byEntityId.useQuery({ id, type }, { enabled: !!id });
|
||||
|
||||
const mutationMap = {
|
||||
application: () => api.patch.delete.useMutation(),
|
||||
compose: () => api.patch.delete.useMutation(),
|
||||
};
|
||||
|
||||
const ensureRepo = api.patch.ensureRepo.useMutation();
|
||||
|
||||
const togglePatch = api.patch.toggleEnabled.useMutation();
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.patch.delete.useMutation();
|
||||
|
||||
const handleCloseEditor = () => {
|
||||
setSelectedFile(null);
|
||||
setRepoPath(null);
|
||||
};
|
||||
|
||||
if (repoPath) {
|
||||
return (
|
||||
<PatchEditor
|
||||
id={id}
|
||||
type={type}
|
||||
repoPath={repoPath || ""}
|
||||
onClose={handleCloseEditor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleOpenEditor = async () => {
|
||||
setIsLoadingRepo(true);
|
||||
await ensureRepo
|
||||
.mutateAsync({ id, type })
|
||||
.then((result) => {
|
||||
setRepoPath(result);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingRepo(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Patches</CardTitle>
|
||||
<CardDescription>
|
||||
Apply code patches to your repository during build. Patches are
|
||||
applied after cloning the repository and before building.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{patches && patches?.length > 0 && (
|
||||
<Button onClick={handleOpenEditor} disabled={isLoadingRepo}>
|
||||
{isLoadingRepo && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
<FilePlus2 className="mr-2 h-4 w-4" />
|
||||
Create Patch
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isPatchesLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : patches?.length === 0 ? (
|
||||
<div className="flex min-h-[40vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
|
||||
<div className="rounded-full bg-muted p-4">
|
||||
<FilePlus2 className="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1 text-center">
|
||||
<p className="text-sm font-medium">No patches yet</p>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
Add file patches to modify your repo before each build—configs,
|
||||
env, or code. Create your first patch to get started.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleOpenEditor} disabled={isLoadingRepo}>
|
||||
{isLoadingRepo && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
<FilePlus2 className="mr-2 h-4 w-4" />
|
||||
Create Patch
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>File Path</TableHead>
|
||||
<TableHead className="w-[80px]">Type</TableHead>
|
||||
<TableHead className="w-[100px]">Enabled</TableHead>
|
||||
<TableHead className="w-[100px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{patches?.map((patch) => (
|
||||
<TableRow key={patch.patchId}>
|
||||
<TableCell className="font-mono text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<File className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
{patch.filePath}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
patch.type === "delete"
|
||||
? "destructive"
|
||||
: patch.type === "create"
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
className="font-normal"
|
||||
>
|
||||
{patch.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={patch.enabled}
|
||||
onCheckedChange={(checked) => {
|
||||
togglePatch
|
||||
.mutateAsync({
|
||||
patchId: patch.patchId,
|
||||
enabled: checked,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Patch updated");
|
||||
utils.patch.byEntityId.invalidate({
|
||||
id,
|
||||
type,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingRepo(false);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
{(patch.type === "update" || patch.type === "create") && (
|
||||
<EditPatchDialog
|
||||
patchId={patch.patchId}
|
||||
entityId={id}
|
||||
type={type}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
mutateAsync({ patchId: patch.patchId })
|
||||
.then(() => {
|
||||
toast.success("Patch deleted");
|
||||
utils.patch.byEntityId.invalidate({
|
||||
id,
|
||||
type,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
}}
|
||||
title="Delete patch"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Dices } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -75,11 +75,11 @@ export const AddPreviewDomain = ({
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync, isError, error, isLoading } = domainId
|
||||
const { mutateAsync, isError, error, isPending } = domainId
|
||||
? api.domain.update.useMutation()
|
||||
: api.domain.create.useMutation();
|
||||
|
||||
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
|
||||
const { mutateAsync: generateDomain, isPending: isLoadingGenerate } =
|
||||
api.domain.generateDomain.useMutation();
|
||||
|
||||
const form = useForm<Domain>({
|
||||
@@ -103,7 +103,7 @@ export const AddPreviewDomain = ({
|
||||
if (!domainId) {
|
||||
form.reset({});
|
||||
}
|
||||
}, [form, form.reset, data, isLoading]);
|
||||
}, [form, form.reset, data, isPending]);
|
||||
|
||||
const dictionary = {
|
||||
success: domainId ? "Domain Updated" : "Domain Created",
|
||||
@@ -301,7 +301,7 @@ export const AddPreviewDomain = ({
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button isLoading={isLoading} form="hook-form" type="submit">
|
||||
<Button isLoading={isPending} form="hook-form" type="submit">
|
||||
{dictionary.submit}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -43,7 +43,7 @@ interface Props {
|
||||
export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||
const { data } = api.application.one.useQuery({ applicationId });
|
||||
|
||||
const { mutateAsync: deletePreviewDeployment, isLoading } =
|
||||
const { mutateAsync: deletePreviewDeployment, isPending } =
|
||||
api.previewDeployment.delete.useMutation();
|
||||
|
||||
const { mutateAsync: redeployPreviewDeployment } =
|
||||
@@ -57,8 +57,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||
{ applicationId },
|
||||
{
|
||||
enabled: !!applicationId,
|
||||
refetchInterval: (data) =>
|
||||
data?.some((d) => d.previewStatus === "running") ? 2000 : false,
|
||||
refetchInterval: 2000,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -282,7 +281,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
isLoading={isLoading}
|
||||
isLoading={isPending}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { HelpCircle, Plus, Settings2, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -80,7 +80,7 @@ interface Props {
|
||||
export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const { mutateAsync: updateApplication, isLoading } =
|
||||
const { mutateAsync: updateApplication, isPending } =
|
||||
api.application.update.useMutation();
|
||||
|
||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||
@@ -535,7 +535,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
isLoading={isPending}
|
||||
form="hook-form-delete-application"
|
||||
type="submit"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -71,7 +71,7 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync: updateApplication, isLoading } =
|
||||
const { mutateAsync: updateApplication, isPending } =
|
||||
api.application.update.useMutation();
|
||||
|
||||
const { data: registries } = api.registry.all.useQuery();
|
||||
@@ -212,7 +212,7 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={isLoading}>
|
||||
<Button type="submit" className="w-full" isLoading={isPending}>
|
||||
Save Settings
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronsUpDown,
|
||||
@@ -220,8 +220,8 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
||||
const utils = api.useUtils();
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
cronExpression: "",
|
||||
@@ -275,11 +275,11 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
}
|
||||
}, [form, schedule, scheduleId]);
|
||||
|
||||
const { mutateAsync, isLoading } = scheduleId
|
||||
const { mutateAsync, isPending } = scheduleId
|
||||
? api.schedule.update.useMutation()
|
||||
: api.schedule.create.useMutation();
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
const onSubmit = async (values: z.output<typeof formSchema>) => {
|
||||
if (!id && !scheduleId) return;
|
||||
|
||||
await mutateAsync({
|
||||
@@ -662,7 +662,7 @@ echo "Hello, world!"
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" isLoading={isLoading} className="w-full">
|
||||
<Button type="submit" isLoading={isPending} className="w-full">
|
||||
{scheduleId ? "Update" : "Create"} Schedule
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -51,7 +51,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
},
|
||||
);
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync: deleteSchedule, isLoading: isDeleting } =
|
||||
const { mutateAsync: deleteSchedule, isPending: isDeleting } =
|
||||
api.schedule.delete.useMutation();
|
||||
const { mutateAsync: runManually } = api.schedule.runManually.useMutation();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -43,7 +43,7 @@ interface Props {
|
||||
export const UpdateApplication = ({ applicationId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, error, isError, isLoading } =
|
||||
const { mutateAsync, error, isError, isPending } =
|
||||
api.application.update.useMutation();
|
||||
const { data } = api.application.one.useQuery(
|
||||
{
|
||||
@@ -148,7 +148,7 @@ export const UpdateApplication = ({ applicationId }: Props) => {
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
isLoading={isPending}
|
||||
form="hook-form-update-application"
|
||||
type="submit"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { DatabaseZap, PenBoxIcon, PlusCircle, RefreshCw } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -71,6 +71,7 @@ const formSchema = z
|
||||
"mongo",
|
||||
"mysql",
|
||||
"redis",
|
||||
"libsql",
|
||||
]),
|
||||
serviceName: z.string(),
|
||||
destinationId: z.string().min(1, "Destination required"),
|
||||
@@ -116,7 +117,7 @@ export const HandleVolumeBackups = ({
|
||||
const [keepLatestCountInput, setKeepLatestCountInput] = useState("");
|
||||
|
||||
const utils = api.useUtils();
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
@@ -195,7 +196,7 @@ export const HandleVolumeBackups = ({
|
||||
}
|
||||
}, [form, volumeBackup, volumeBackupId]);
|
||||
|
||||
const { mutateAsync, isLoading } = volumeBackupId
|
||||
const { mutateAsync, isPending } = volumeBackupId
|
||||
? api.volumeBackups.update.useMutation()
|
||||
: api.volumeBackups.create.useMutation();
|
||||
|
||||
@@ -207,7 +208,7 @@ export const HandleVolumeBackups = ({
|
||||
|
||||
await mutateAsync({
|
||||
...values,
|
||||
keepLatestCount: preparedKeepLatestCount,
|
||||
keepLatestCount: preparedKeepLatestCount ?? undefined,
|
||||
destinationId: values.destinationId,
|
||||
volumeBackupId: volumeBackupId || "",
|
||||
serviceType: volumeBackupType,
|
||||
@@ -482,7 +483,7 @@ export const HandleVolumeBackups = ({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose the volume to backup, if you dont see the
|
||||
Choose the volume to backup. If you do not see the
|
||||
volume here, you can type the volume name manually
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
@@ -517,7 +518,7 @@ export const HandleVolumeBackups = ({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose the volume to backup, if you dont see the volume
|
||||
Choose the volume to backup. If you do not see the volume
|
||||
here, you can type the volume name manually
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
@@ -630,7 +631,7 @@ export const HandleVolumeBackups = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" isLoading={isLoading} className="w-full">
|
||||
<Button type="submit" isLoading={isPending} className="w-full">
|
||||
{volumeBackupId ? "Update" : "Create"} Volume Backup
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { debounce } from "lodash";
|
||||
import debounce from "lodash/debounce";
|
||||
import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -53,27 +53,15 @@ interface Props {
|
||||
}
|
||||
|
||||
const RestoreBackupSchema = z.object({
|
||||
destinationId: z
|
||||
.string({
|
||||
required_error: "Please select a destination",
|
||||
})
|
||||
.min(1, {
|
||||
message: "Destination is required",
|
||||
}),
|
||||
backupFile: z
|
||||
.string({
|
||||
required_error: "Please select a backup file",
|
||||
})
|
||||
.min(1, {
|
||||
message: "Backup file is required",
|
||||
}),
|
||||
volumeName: z
|
||||
.string({
|
||||
required_error: "Please enter a volume name",
|
||||
})
|
||||
.min(1, {
|
||||
message: "Volume name is required",
|
||||
}),
|
||||
destinationId: z.string().min(1, {
|
||||
message: "Destination is required",
|
||||
}),
|
||||
backupFile: z.string().min(1, {
|
||||
message: "Backup file is required",
|
||||
}),
|
||||
volumeName: z.string().min(1, {
|
||||
message: "Volume name is required",
|
||||
}),
|
||||
});
|
||||
|
||||
export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
||||
@@ -83,7 +71,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
||||
|
||||
const { data: destinations = [] } = api.destination.all.useQuery();
|
||||
|
||||
const form = useForm<z.infer<typeof RestoreBackupSchema>>({
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
destinationId: "",
|
||||
backupFile: "",
|
||||
@@ -105,7 +93,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
||||
debouncedSetSearch(value);
|
||||
};
|
||||
|
||||
const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery(
|
||||
const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery(
|
||||
{
|
||||
destinationId: destinationId,
|
||||
search: debouncedSearchTerm,
|
||||
@@ -294,7 +282,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
||||
onValueChange={handleSearchChange}
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoading ? (
|
||||
{isPending ? (
|
||||
<div className="py-6 text-center text-sm">
|
||||
Loading backup files...
|
||||
</div>
|
||||
|
||||
@@ -54,7 +54,7 @@ export const ShowVolumeBackups = ({
|
||||
},
|
||||
);
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync: deleteVolumeBackup, isLoading: isDeleting } =
|
||||
const { mutateAsync: deleteVolumeBackup, isPending: isDeleting } =
|
||||
api.volumeBackups.delete.useMutation();
|
||||
const { mutateAsync: runManually } =
|
||||
api.volumeBackups.runManually.useMutation();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -52,7 +52,7 @@ export const AddCommandCompose = ({ composeId }: Props) => {
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { mutateAsync, isLoading } = api.compose.update.useMutation();
|
||||
const { mutateAsync, isPending } = api.compose.update.useMutation();
|
||||
|
||||
const form = useForm<AddCommand>({
|
||||
defaultValues: {
|
||||
@@ -128,7 +128,7 @@ export const AddCommandCompose = ({ composeId }: Props) => {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button isLoading={isLoading} type="submit" className="w-fit">
|
||||
<Button isLoading={isPending} type="submit" className="w-fit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ServiceType } from "@dokploy/server/db/schema";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { Copy, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -46,6 +46,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export const DeleteService = ({ id, type }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canDelete = permissions?.service.delete ?? false;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const queryMap = {
|
||||
@@ -55,6 +57,7 @@ export const DeleteService = ({ id, type }: Props) => {
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
@@ -70,11 +73,12 @@ export const DeleteService = ({ id, type }: Props) => {
|
||||
redis: () => api.redis.remove.useMutation(),
|
||||
mysql: () => api.mysql.remove.useMutation(),
|
||||
mariadb: () => api.mariadb.remove.useMutation(),
|
||||
libsql: () => api.libsql.remove.useMutation(),
|
||||
application: () => api.application.delete.useMutation(),
|
||||
mongo: () => api.mongo.remove.useMutation(),
|
||||
compose: () => api.compose.delete.useMutation(),
|
||||
};
|
||||
const { mutateAsync, isLoading } = mutationMap[type]
|
||||
const { mutateAsync, isPending } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.mongo.remove.useMutation();
|
||||
const { push } = useRouter();
|
||||
@@ -96,6 +100,7 @@ export const DeleteService = ({ id, type }: Props) => {
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
libsqlId: id || "",
|
||||
applicationId: id || "",
|
||||
composeId: id || "",
|
||||
deleteVolumes,
|
||||
@@ -123,6 +128,8 @@ export const DeleteService = ({ id, type }: Props) => {
|
||||
data?.applicationStatus === "running") ||
|
||||
(data && "composeStatus" in data && data?.composeStatus === "running");
|
||||
|
||||
if (!canDelete) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -130,7 +137,7 @@ export const DeleteService = ({ id, type }: Props) => {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isLoading}
|
||||
isLoading={isPending}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
@@ -228,7 +235,7 @@ export const DeleteService = ({ id, type }: Props) => {
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
isLoading={isPending}
|
||||
disabled={isDisabled}
|
||||
form="hook-form-delete-compose"
|
||||
type="submit"
|
||||
|
||||
@@ -19,6 +19,9 @@ interface Props {
|
||||
}
|
||||
export const ComposeActions = ({ composeId }: Props) => {
|
||||
const router = useRouter();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canDeploy = permissions?.deployment.create ?? false;
|
||||
const canUpdateService = permissions?.service.create ?? false;
|
||||
const { data, refetch } = api.compose.one.useQuery(
|
||||
{
|
||||
composeId,
|
||||
@@ -28,169 +31,176 @@ export const ComposeActions = ({ composeId }: Props) => {
|
||||
const { mutateAsync: update } = api.compose.update.useMutation();
|
||||
const { mutateAsync: deploy } = api.compose.deploy.useMutation();
|
||||
const { mutateAsync: redeploy } = api.compose.redeploy.useMutation();
|
||||
const { mutateAsync: start, isLoading: isStarting } =
|
||||
const { mutateAsync: start, isPending: isStarting } =
|
||||
api.compose.start.useMutation();
|
||||
const { mutateAsync: stop, isLoading: isStopping } =
|
||||
const { mutateAsync: stop, isPending: isStopping } =
|
||||
api.compose.stop.useMutation();
|
||||
return (
|
||||
<div className="flex flex-row gap-4 w-full flex-wrap ">
|
||||
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
||||
<DialogAction
|
||||
title="Deploy Compose"
|
||||
description="Are you sure you want to deploy this compose?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await deploy({
|
||||
composeId: composeId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Compose deployed successfully");
|
||||
refetch();
|
||||
router.push(
|
||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deploying compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.composeStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Downloads the source code and performs a complete build</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<DialogAction
|
||||
title="Reload Compose"
|
||||
description="Are you sure you want to reload this compose?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await redeploy({
|
||||
composeId: composeId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Compose reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={data?.composeStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Reload the compose without rebuilding it</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
{data?.composeType === "docker-compose" &&
|
||||
data?.composeStatus === "idle" ? (
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Start Compose"
|
||||
description="Are you sure you want to start this compose?"
|
||||
title="Deploy Compose"
|
||||
description="Are you sure you want to deploy this compose?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
await deploy({
|
||||
composeId: composeId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Compose started successfully");
|
||||
toast.success("Compose deployed successfully");
|
||||
refetch();
|
||||
router.push(
|
||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting compose");
|
||||
toast.error("Error deploying compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
variant="default"
|
||||
isLoading={data?.composeStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the compose (requires a previous successful build)
|
||||
Downloads the source code and performs a complete build
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
)}
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Stop Compose"
|
||||
description="Are you sure you want to stop this compose?"
|
||||
title="Reload Compose"
|
||||
description="Are you sure you want to reload this compose?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
await redeploy({
|
||||
composeId: composeId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Compose stopped successfully");
|
||||
toast.success("Compose reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping compose");
|
||||
toast.error("Error reloading compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
variant="secondary"
|
||||
isLoading={data?.composeStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running compose</p>
|
||||
<p>Reload the compose without rebuilding it</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy &&
|
||||
(data?.composeType === "docker-compose" &&
|
||||
data?.composeStatus === "idle" ? (
|
||||
<DialogAction
|
||||
title="Start Compose"
|
||||
description="Are you sure you want to start this compose?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
composeId: composeId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Compose started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the compose (requires a previous successful build)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop Compose"
|
||||
description="Are you sure you want to stop this compose?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
composeId: composeId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Compose stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running compose</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
@@ -205,27 +215,29 @@ export const ComposeActions = ({ composeId }: Props) => {
|
||||
Open Terminal
|
||||
</Button>
|
||||
</DockerTerminalModal>
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Autodeploy</span>
|
||||
<Switch
|
||||
aria-label="Toggle autodeploy"
|
||||
checked={data?.autoDeploy || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
composeId,
|
||||
autoDeploy: enabled,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Auto Deploy Updated");
|
||||
await refetch();
|
||||
{canUpdateService && (
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Autodeploy</span>
|
||||
<Switch
|
||||
aria-label="Toggle autodeploy"
|
||||
checked={data?.autoDeploy || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
composeId,
|
||||
autoDeploy: enabled,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Auto Deploy");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
.then(async () => {
|
||||
toast.success("Auto Deploy Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Auto Deploy");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -26,6 +26,8 @@ const AddComposeFile = z.object({
|
||||
type AddComposeFile = z.infer<typeof AddComposeFile>;
|
||||
|
||||
export const ComposeFileEditor = ({ composeId }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canUpdate = permissions?.service.create ?? false;
|
||||
const utils = api.useUtils();
|
||||
const { data, refetch } = api.compose.one.useQuery(
|
||||
{
|
||||
@@ -34,7 +36,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
||||
{ enabled: !!composeId },
|
||||
);
|
||||
|
||||
const { mutateAsync, isLoading } = api.compose.update.useMutation();
|
||||
const { mutateAsync, isPending } = api.compose.update.useMutation();
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
const form = useForm<AddComposeFile>({
|
||||
@@ -93,7 +95,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
||||
e.preventDefault();
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
@@ -103,7 +105,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [form, onSubmit, isLoading]);
|
||||
}, [form, onSubmit, isPending]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -164,14 +166,16 @@ services:
|
||||
</Form>
|
||||
<div className="flex justify-between flex-col lg:flex-row gap-2">
|
||||
<div className="w-full flex flex-col lg:flex-row gap-4 items-end" />
|
||||
<Button
|
||||
type="submit"
|
||||
form="hook-form-save-compose-file"
|
||||
isLoading={isLoading}
|
||||
className="lg:w-fit w-full"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
{canUpdate && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="hook-form-save-compose-file"
|
||||
isLoading={isPending}
|
||||
className="lg:w-fit w-full"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
@@ -74,10 +74,10 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
api.bitbucket.bitbucketProviders.useQuery();
|
||||
const { data, refetch } = api.compose.one.useQuery({ composeId });
|
||||
|
||||
const { mutateAsync, isLoading: isSavingBitbucketProvider } =
|
||||
const { mutateAsync, isPending: isSavingBitbucketProvider } =
|
||||
api.compose.update.useMutation();
|
||||
|
||||
const form = useForm<BitbucketProvider>({
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
composePath: "./docker-compose.yml",
|
||||
repository: {
|
||||
@@ -247,13 +247,13 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -265,11 +265,15 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!bitbucketId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a Bitbucket account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
@@ -331,7 +335,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status === "loading" && fetchStatus === "fetching"
|
||||
{status === "pending" && fetchStatus === "fetching"
|
||||
? "Loading...."
|
||||
: field.value
|
||||
? branches?.find(
|
||||
@@ -348,7 +352,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
placeholder="Search branch..."
|
||||
className="h-9"
|
||||
/>
|
||||
{status === "loading" && fetchStatus === "fetching" && (
|
||||
{status === "pending" && fetchStatus === "fetching" && (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Loading Branches....
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
@@ -58,9 +58,9 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync, isLoading } = api.compose.update.useMutation();
|
||||
const { mutateAsync, isPending } = api.compose.update.useMutation();
|
||||
|
||||
const form = useForm<GitProvider>({
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
branch: "",
|
||||
repositoryURL: "",
|
||||
@@ -230,10 +230,8 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[300px]">
|
||||
<p>
|
||||
@@ -318,7 +316,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end">
|
||||
<Button type="submit" className="w-fit" isLoading={isLoading}>
|
||||
<Button type="submit" className="w-fit" isLoading={isPending}>
|
||||
Save{" "}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, Plus, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
@@ -72,10 +72,10 @@ interface Props {
|
||||
export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
|
||||
const { data, refetch } = api.compose.one.useQuery({ composeId });
|
||||
const { mutateAsync, isLoading: isSavingGiteaProvider } =
|
||||
const { mutateAsync, isPending: isSavingGiteaProvider } =
|
||||
api.compose.update.useMutation();
|
||||
|
||||
const form = useForm<GiteaProvider>({
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
composePath: "./docker-compose.yml",
|
||||
repository: {
|
||||
@@ -244,13 +244,13 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
@@ -261,11 +261,15 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!giteaId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a Gitea account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
@@ -327,7 +331,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status === "loading" && fetchStatus === "fetching"
|
||||
{status === "pending" && fetchStatus === "fetching"
|
||||
? "Loading...."
|
||||
: field.value
|
||||
? branches?.find(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
@@ -72,10 +72,10 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
const { data: githubProviders } = api.github.githubProviders.useQuery();
|
||||
const { data, refetch } = api.compose.one.useQuery({ composeId });
|
||||
|
||||
const { mutateAsync, isLoading: isSavingGithubProvider } =
|
||||
const { mutateAsync, isPending: isSavingGithubProvider } =
|
||||
api.compose.update.useMutation();
|
||||
|
||||
const form = useForm<GithubProvider>({
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
composePath: "./docker-compose.yml",
|
||||
repository: {
|
||||
@@ -94,7 +94,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
const repository = form.watch("repository");
|
||||
const githubId = form.watch("githubId");
|
||||
const triggerType = form.watch("triggerType");
|
||||
const { data: repositories, isLoading: isLoadingRepositories } =
|
||||
const { data: repositories, isPending: isLoadingRepositories } =
|
||||
api.github.getGithubRepositories.useQuery(
|
||||
{
|
||||
githubId,
|
||||
@@ -234,13 +234,13 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -252,11 +252,15 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!githubId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a GitHub account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
@@ -317,7 +321,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status === "loading" && fetchStatus === "fetching"
|
||||
{status === "pending" && fetchStatus === "fetching"
|
||||
? "Loading...."
|
||||
: field.value
|
||||
? branches?.find(
|
||||
@@ -334,7 +338,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
placeholder="Search branch..."
|
||||
className="h-9"
|
||||
/>
|
||||
{status === "loading" && fetchStatus === "fetching" && (
|
||||
{status === "pending" && fetchStatus === "fetching" && (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Loading Branches....
|
||||
</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo } from "react";
|
||||
@@ -74,10 +74,10 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
|
||||
const { data, refetch } = api.compose.one.useQuery({ composeId });
|
||||
|
||||
const { mutateAsync, isLoading: isSavingGitlabProvider } =
|
||||
const { mutateAsync, isPending: isSavingGitlabProvider } =
|
||||
api.compose.update.useMutation();
|
||||
|
||||
const form = useForm<GitlabProvider>({
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
composePath: "./docker-compose.yml",
|
||||
repository: {
|
||||
@@ -256,13 +256,13 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -274,11 +274,15 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!gitlabId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a GitLab account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
@@ -349,7 +353,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status === "loading" && fetchStatus === "fetching"
|
||||
{status === "pending" && fetchStatus === "fetching"
|
||||
? "Loading...."
|
||||
: field.value
|
||||
? branches?.find(
|
||||
@@ -366,7 +370,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
placeholder="Search branch..."
|
||||
className="h-9"
|
||||
/>
|
||||
{status === "loading" && fetchStatus === "fetching" && (
|
||||
{status === "pending" && fetchStatus === "fetching" && (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Loading Branches....
|
||||
</span>
|
||||
|
||||
@@ -27,13 +27,13 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
||||
const { data: githubProviders, isLoading: isLoadingGithub } =
|
||||
const { data: githubProviders, isPending: isLoadingGithub } =
|
||||
api.github.githubProviders.useQuery();
|
||||
const { data: gitlabProviders, isLoading: isLoadingGitlab } =
|
||||
const { data: gitlabProviders, isPending: isLoadingGitlab } =
|
||||
api.gitlab.gitlabProviders.useQuery();
|
||||
const { data: bitbucketProviders, isLoading: isLoadingBitbucket } =
|
||||
const { data: bitbucketProviders, isPending: isLoadingBitbucket } =
|
||||
api.bitbucket.bitbucketProviders.useQuery();
|
||||
const { data: giteaProviders, isLoading: isLoadingGitea } =
|
||||
const { data: giteaProviders, isPending: isLoadingGitea } =
|
||||
api.gitea.giteaProviders.useQuery();
|
||||
|
||||
const { mutateAsync: disconnectGitProvider } =
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user