Merge branch 'canary' into feat/add-custom-dokploy-loader

This commit is contained in:
Mauricio Siu
2026-03-01 15:53:33 -06:00
427 changed files with 58888 additions and 6540 deletions

View File

@@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"dev": "PORT=4000 tsx watch src/index.ts",
"build": "tsc --project tsconfig.json",
"build": "rimraf dist && tsc --project tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"
},
@@ -12,7 +12,7 @@
"inngest": "3.40.1",
"@dokploy/server": "workspace:*",
"@hono/node-server": "^1.14.3",
"@hono/zod-validator": "0.3.0",
"@hono/zod-validator": "0.7.6",
"dotenv": "^16.4.5",
"hono": "^4.11.7",
"pino": "9.4.0",
@@ -20,18 +20,19 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"redis": "4.7.0",
"zod": "^3.25.32"
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^20.16.0",
"@types/node": "^24.4.0",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"rimraf": "6.1.3",
"tsx": "^4.16.2",
"typescript": "^5.8.3"
},
"packageManager": "pnpm@9.12.0",
"packageManager": "pnpm@10.22.0",
"engines": {
"node": "^20.16.0",
"pnpm": ">=9.12.0"
"node": "^24.4.0",
"pnpm": ">=10.22.0"
}
}

View File

@@ -1 +0,0 @@
20.16.0

View File

@@ -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([]),
},
},
},
};

View File

@@ -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([]),
},
},
},
};

View File

@@ -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",
);
});
});

View 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();
});
});

View File

@@ -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,
}),
};
});
@@ -147,8 +151,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 () => {
@@ -165,14 +340,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 {
}
});

View File

@@ -0,0 +1 @@
/etc/passwd

View File

@@ -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");
});
});

View File

@@ -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: {

View File

@@ -125,6 +125,7 @@ const baseApp: ApplicationNested = {
username: null,
dockerContextPath: null,
stopGracePeriodSwarm: null,
ulimitsSwarm: null,
};
const baseDomain: Domain = {
@@ -274,3 +275,51 @@ test("CertificateType on websecure entrypoint", async () => {
expect(router.tls?.certResolver).toBe("letsencrypt");
});
/** 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("тест.рф");
});

View 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);
});
});

View 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);
});
});

View File

@@ -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";
@@ -73,7 +73,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync, isLoading } = mutationMap[type]
const { mutateAsync, isPending } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
@@ -236,7 +236,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>

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -105,7 +105,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({

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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>

View File

@@ -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();

View File

@@ -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"
>

View File

@@ -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 (

View File

@@ -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";
@@ -100,11 +100,11 @@ 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: "",
@@ -268,7 +268,7 @@ export const HandleRedirect = ({
<DialogFooter>
<Button
isLoading={isLoading}
isLoading={isPending}
form="hook-form-add-redirect"
type="submit"
>

View File

@@ -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();

View File

@@ -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"
>

View File

@@ -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();

View File

@@ -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>

View File

@@ -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,13 +58,36 @@ 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"
@@ -97,20 +128,26 @@ export const ShowResources = ({ id, type }: Props) => {
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,6 +155,7 @@ export const ShowResources = ({ id, type }: Props) => {
cpuReservation: data?.cpuReservation || undefined,
memoryLimit: data?.memoryLimit || undefined,
memoryReservation: data?.memoryReservation || undefined,
ulimitsSwarm: data?.ulimitsSwarm || [],
});
}
}, [data, form, form.reset]);
@@ -134,6 +172,10 @@ export const ShowResources = ({ id, type }: Props) => {
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 +367,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 &quot;Add Ulimit&quot; 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>

View File

@@ -15,7 +15,7 @@ interface Props {
}
export const ShowTraefikConfig = ({ applicationId }: Props) => {
const { data, isLoading } = api.application.readTraefikConfig.useQuery(
const { data, isPending } = api.application.readTraefikConfig.useQuery(
{
applicationId,
},
@@ -35,7 +35,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" />

View File

@@ -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,7 +25,6 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
@@ -69,7 +69,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>({
@@ -126,7 +126,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
}}
>
<DialogTrigger asChild>
<Button isLoading={isLoading}>Modify</Button>
<Button isLoading={isPending}>Modify</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
@@ -198,7 +198,7 @@ routers:
</p>
</div>
<Button
isLoading={isLoading}
isLoading={isPending}
form="hook-form-update-traefik-config"
type="submit"
>

View File

@@ -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";

View File

@@ -37,7 +37,7 @@ export const ShowVolumes = ({ id, type }: Props) => {
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">

View File

@@ -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";
@@ -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>
@@ -310,7 +310,7 @@ PORT=3000
</div>
<DialogFooter>
<Button
isLoading={isLoading}
isLoading={isPending}
// form="hook-form-update-volume"
type="submit"
>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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">

View File

@@ -6,6 +6,7 @@ import {
RefreshCcw,
RocketIcon,
Settings,
Trash2,
} from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -25,6 +26,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 +61,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 +75,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("");
@@ -144,6 +148,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} />
)}
@@ -252,6 +259,8 @@ export const ShowDeployments = ({
const isExpanded = expandedDescriptions.has(
deployment.deploymentId,
);
const canDelete =
deployment.status === "done" || deployment.status === "error";
return (
<div
@@ -370,6 +379,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" && (

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
@@ -159,11 +159,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 } =
@@ -240,7 +240,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
domainType: type,
});
}
}, [form, data, isLoading, domainId]);
}, [form, data, isPending, domainId]);
// Separate effect for handling custom cert resolver validation
useEffect(() => {
@@ -730,7 +730,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
</form>
<DialogFooter>
<Button isLoading={isLoading} form="hook-form" type="submit">
<Button isLoading={isPending} form="hook-form" type="submit">
{dictionary.submit}
</Button>
</DialogFooter>

View File

@@ -97,7 +97,7 @@ 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 handleValidateDomain = async (host: string) => {

View File

@@ -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";
@@ -60,7 +60,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
mongo: () => api.mongo.update.useMutation(),
compose: () => api.compose.update.useMutation(),
};
const { mutateAsync, isLoading } = mutationMap[type]
const { mutateAsync, isPending } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
@@ -111,7 +111,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 +121,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 ">
@@ -196,7 +196,7 @@ PORT=3000
</Button>
)}
<Button
isLoading={isLoading}
isLoading={isPending}
className="w-fit"
type="submit"
disabled={!hasChanges}

View File

@@ -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,7 @@ interface Props {
}
export const ShowEnvironment = ({ applicationId }: Props) => {
const { mutateAsync, isLoading } =
const { mutateAsync, isPending } =
api.application.saveEnvironment.useMutation();
const { data, refetch } = api.application.one.useQuery(
@@ -104,7 +104,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 +114,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">
@@ -214,7 +214,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
</Button>
)}
<Button
isLoading={isLoading}
isLoading={isPending}
className="w-fit"
type="submit"
disabled={!hasChanges}

View File

@@ -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 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>

View File

@@ -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";

View File

@@ -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>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -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: "/",
@@ -317,7 +317,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>

View File

@@ -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 = "";
}
}}

View File

@@ -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"
>

View File

@@ -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"
>

View File

@@ -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({

View File

@@ -37,14 +37,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();

View File

@@ -56,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,
@@ -67,7 +67,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
},
);
const { data: containers, isLoading: containersLoading } =
const { data: containers, isPending: containersLoading } =
api.docker.getContainersByAppNameMatch.useQuery(
{
appName,

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -0,0 +1,2 @@
export * from "./show-patches";
export * from "./patch-editor";

View File

@@ -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>
);
};

View File

@@ -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 buildconfigs,
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>
);
};

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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"
>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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();

View File

@@ -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"
>

View File

@@ -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";
@@ -116,7 +116,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 +195,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 +207,7 @@ export const HandleVolumeBackups = ({
await mutateAsync({
...values,
keepLatestCount: preparedKeepLatestCount,
keepLatestCount: preparedKeepLatestCount ?? undefined,
destinationId: values.destinationId,
volumeBackupId: volumeBackupId || "",
serviceType: volumeBackupType,
@@ -630,7 +630,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>

View File

@@ -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>

View File

@@ -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();

View File

@@ -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>

View File

@@ -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";

View File

@@ -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";
@@ -74,7 +74,7 @@ export const DeleteService = ({ id, type }: Props) => {
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();
@@ -130,7 +130,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 +228,7 @@ export const DeleteService = ({ id, type }: Props) => {
</Button>
<Button
isLoading={isLoading}
isLoading={isPending}
disabled={isDisabled}
form="hook-form-delete-compose"
type="submit"

View File

@@ -28,9 +28,9 @@ 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 ">

View File

@@ -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,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 +93,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 +103,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [form, onSubmit, isLoading]);
}, [form, onSubmit, isPending]);
return (
<>
@@ -167,7 +167,7 @@ services:
<Button
type="submit"
form="hook-form-save-compose-file"
isLoading={isLoading}
isLoading={isPending}
className="lg:w-fit w-full"
>
Save

View File

@@ -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>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -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: "",
@@ -318,7 +318,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>

View File

@@ -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(

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 } =

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { AlertTriangle } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";

View File

@@ -32,7 +32,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
},
);
const { mutateAsync, isLoading } = api.compose.fetchSourceType.useMutation();
const { mutateAsync, isPending } = api.compose.fetchSourceType.useMutation();
useEffect(() => {
if (isOpen) {
@@ -66,7 +66,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
Preview your docker-compose file with added domains. Note: At least
one domain must be specified for this conversion to take effect.
</AlertBlock>
{isLoading ? (
{isPending ? (
<div className="flex flex-row items-center justify-center min-h-[25rem] border p-4 rounded-md">
<Loader2 className="h-8 w-8 text-muted-foreground mb-2 animate-spin" />
</div>
@@ -82,7 +82,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
<div className="flex flex-row gap-2 justify-end my-4">
<Button
variant="secondary"
isLoading={isLoading}
isLoading={isPending}
onClick={() => {
mutateAsync({ composeId })
.then(() => {

View File

@@ -41,7 +41,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
const [option, setOption] = useState<"swarm" | "native">("native");
const [containerId, setContainerId] = useState<string | undefined>();
const { data: services, isLoading: servicesLoading } =
const { data: services, isPending: servicesLoading } =
api.docker.getStackContainersByAppName.useQuery(
{
appName,
@@ -52,7 +52,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
},
);
const { data: containers, isLoading: containersLoading } =
const { data: containers, isPending: containersLoading } =
api.docker.getContainersByAppNameMatch.useQuery(
{
appName,

View File

@@ -1,8 +1,8 @@
import { Loader2 } from "lucide-react";
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
import { Badge } from "@/components/ui/badge";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
@@ -42,7 +42,7 @@ export const ShowDockerLogsCompose = ({
appType,
serverId,
}: Props) => {
const { data, isLoading } = api.docker.getContainersByAppNameMatch.useQuery(
const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
appType,
@@ -73,7 +73,7 @@ export const ShowDockerLogsCompose = ({
<Label>Select a container to view logs</Label>
<Select onValueChange={setContainerId} value={containerId}>
<SelectTrigger>
{isLoading ? (
{isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />

View File

@@ -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 UpdateCompose = ({ composeId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, error, isError, isLoading } =
const { mutateAsync, error, isError, isPending } =
api.compose.update.useMutation();
const { data } = api.compose.one.useQuery(
{
@@ -148,7 +148,7 @@ export const UpdateCompose = ({ composeId }: Props) => {
/>
<DialogFooter>
<Button
isLoading={isLoading}
isLoading={isPending}
form="hook-form-update-compose"
type="submit"
>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import {
CheckIcon,
ChevronsUpDown,
@@ -192,7 +192,7 @@ export const HandleBackup = ({
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data, isLoading } = api.destination.all.useQuery();
const { data, isPending } = api.destination.all.useQuery();
const { data: backup } = api.backup.one.useQuery(
{
backupId: backupId ?? "",
@@ -202,12 +202,12 @@ export const HandleBackup = ({
},
);
const [cacheType, setCacheType] = useState<CacheType>("cache");
const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } =
const { mutateAsync: createBackup, isPending: isCreatingPostgresBackup } =
backupId
? api.backup.update.useMutation()
: api.backup.create.useMutation();
const form = useForm<z.infer<typeof Schema>>({
const form = useForm({
defaultValues: {
database: databaseType === "web-server" ? "dokploy" : "",
destinationId: "",
@@ -396,7 +396,7 @@ export const HandleBackup = ({
!field.value && "text-muted-foreground",
)}
>
{isLoading
{isPending
? "Loading...."
: field.value
? data?.find(
@@ -415,7 +415,7 @@ export const HandleBackup = ({
placeholder="Search Destination..."
className="h-9"
/>
{isLoading && (
{isPending && (
<span className="py-6 text-center text-sm">
Loading Destinations....
</span>
@@ -613,6 +613,7 @@ export const HandleBackup = ({
type="number"
placeholder={"keeps all the backups if left empty"}
{...field}
value={field.value as string}
/>
</FormControl>
<FormDescription>

View File

@@ -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 _ from "lodash";
import debounce from "lodash/debounce";
import {
CheckIcon,
ChevronsUpDown,
@@ -78,27 +78,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",
}),
databaseName: z
.string({
required_error: "Please enter a database name",
})
.min(1, {
message: "Database name is required",
}),
destinationId: z.string().min(1, {
message: "Destination is required",
}),
backupFile: z.string().min(1, {
message: "Backup file is required",
}),
databaseName: z.string().min(1, {
message: "Database name is required",
}),
databaseType: z
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
.optional(),
@@ -219,7 +207,7 @@ export const RestoreBackup = ({
const { data: destinations = [] } = api.destination.all.useQuery();
const form = useForm<z.infer<typeof RestoreBackupSchema>>({
const form = useForm({
defaultValues: {
destinationId: "",
backupFile: "",
@@ -236,7 +224,7 @@ export const RestoreBackup = ({
const currentDatabaseType = form.watch("databaseType");
const metadata = form.watch("metadata");
const debouncedSetSearch = _.debounce((value: string) => {
const debouncedSetSearch = debounce((value: string) => {
setDebouncedSearchTerm(value);
}, 350);
@@ -245,7 +233,7 @@ export const RestoreBackup = ({
debouncedSetSearch(value);
};
const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery(
const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery(
{
destinationId: destionationId,
search: debouncedSearchTerm,
@@ -454,7 +442,7 @@ export const RestoreBackup = ({
onValueChange={handleSearchChange}
className="h-9"
/>
{isLoading ? (
{isPending ? (
<div className="py-6 text-center text-sm">
Loading backup files...
</div>

View File

@@ -89,11 +89,11 @@ export const ShowBackups = ({
const mutation = mutationMap[key as keyof typeof mutationMap];
const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutation
const { mutateAsync: manualBackup, isPending: isManualBackup } = mutation
? mutation
: api.backup.manualBackupMongo.useMutation();
const { mutateAsync: deleteBackup, isLoading: isRemoving } =
const { mutateAsync: deleteBackup, isPending: isRemoving } =
api.backup.remove.useMutation();
return (

View File

@@ -402,7 +402,7 @@ export const DockerLogsId: React.FC<Props> = ({
{filteredLogs.length > 0 ? (
filteredLogs.map((filteredLog: LogLine, index: number) => (
<TerminalLine
key={index}
key={`${filteredLog.rawTimestamp ?? ""}-${index}`}
log={filteredLog}
searchTerm={search}
noTimestamp={!showTimestamp}

View File

@@ -1,5 +1,5 @@
import { Command as CommandPrimitive } from "cmdk";
import { debounce } from "lodash";
import debounce from "lodash/debounce";
import { CheckIcon, Hash } from "lucide-react";
import React, { useCallback, useRef } from "react";
import { Badge } from "@/components/ui/badge";

View File

@@ -1,5 +1,5 @@
import { FancyAnsi } from "fancy-ansi";
import _ from "lodash";
import escapeRegExp from "lodash/escapeRegExp";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
@@ -47,7 +47,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
}
const htmlContent = fancyAnsi.toHtml(text);
const searchRegex = new RegExp(`(${_.escapeRegExp(term)})`, "gi");
const searchRegex = new RegExp(`(${escapeRegExp(term)})`, "gi");
const modifiedContent = htmlContent.replace(
searchRegex,

View File

@@ -45,7 +45,7 @@ interface Props {
}
export const ShowContainers = ({ serverId }: Props) => {
const { data, isLoading } = api.docker.getContainers.useQuery({
const { data, isPending } = api.docker.getContainers.useQuery({
serverId,
});
@@ -137,7 +137,7 @@ export const ShowContainers = ({ serverId }: Props) => {
</DropdownMenu>
</div>
<div className="rounded-md border">
{isLoading ? (
{isPending ? (
<div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]">
<span className="text-muted-foreground text-lg font-medium">
Loading...
@@ -192,7 +192,7 @@ export const ShowContainers = ({ serverId }: Props) => {
colSpan={columns.length}
className="h-24 text-center"
>
{isLoading ? (
{isPending ? (
<div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]">
<span className="text-muted-foreground text-lg font-medium">
Loading...

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -51,7 +51,7 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => {
const [canEdit, setCanEdit] = useState(true);
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
const { mutateAsync, isLoading, error, isError } =
const { mutateAsync, isPending, error, isError } =
api.settings.updateTraefikFile.useMutation();
const form = useForm<UpdateServerMiddlewareConfig>({
@@ -182,8 +182,8 @@ routers:
</p>
<div className="flex justify-end">
<Button
isLoading={isLoading}
disabled={canEdit || isLoading}
isLoading={isPending}
disabled={canEdit || isLoadingFile}
type="submit"
>
Update

View File

@@ -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";
@@ -48,10 +48,10 @@ interface Props {
export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.mariadb.one.useQuery({ mariadbId });
const { mutateAsync, isLoading } = api.mariadb.saveExternalPort.useMutation();
const { mutateAsync, isPending } = api.mariadb.saveExternalPort.useMutation();
const [connectionUrl, setConnectionUrl] = useState("");
const getIp = data?.server?.ipAddress || ip;
const form = useForm<DockerProvider>({
const form = useForm({
defaultValues: {},
resolver: zodResolver(DockerProviderSchema),
});
@@ -140,7 +140,7 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
<Input
placeholder="3306"
{...field}
value={field.value || ""}
value={field.value as string}
/>
</FormControl>
<FormMessage />
@@ -161,7 +161,7 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
)}
<div className="flex justify-end">
<Button type="submit" isLoading={isLoading}>
<Button type="submit" isLoading={isPending}>
Save
</Button>
</div>

View File

@@ -28,13 +28,13 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
{ enabled: !!mariadbId },
);
const { mutateAsync: reload, isLoading: isReloading } =
const { mutateAsync: reload, isPending: isReloading } =
api.mariadb.reload.useMutation();
const { mutateAsync: start, isLoading: isStarting } =
const { mutateAsync: start, isPending: isStarting } =
api.mariadb.start.useMutation();
const { mutateAsync: stop, isLoading: isStopping } =
const { mutateAsync: stop, isPending: isStopping } =
api.mariadb.stop.useMutation();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);

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