mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-17 05:05:22 +02:00
Compare commits
1 Commits
feat/add-c
...
dosu/doc-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39c771ea6b |
@@ -20,7 +20,7 @@ Dokploy includes multiple features to make your life easier.
|
||||
|
||||
- **Applications**: Deploy any type of application (Node.js, PHP, Python, Go, Ruby, etc.).
|
||||
- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, libsql, and Redis.
|
||||
- **Backups**: Automate backups for databases to an external storage destination.
|
||||
- **Backups**: Automate backups for databases and volumes to external storage destinations (S3, SFTP, FTP, Google Drive).
|
||||
- **Docker Compose**: Native support for Docker Compose to manage complex applications.
|
||||
- **Multi Node**: Scale applications to multiple nodes using Docker Swarm to manage the cluster.
|
||||
- **Templates**: Deploy open-source templates (Plausible, Pocketbase, Calcom, etc.) with a single click.
|
||||
@@ -39,7 +39,7 @@ To get started, run the following command on a VPS:
|
||||
Want to skip the installation process? [Try the Dokploy Cloud](https://app.dokploy.com).
|
||||
|
||||
```bash
|
||||
curl -sSL https://dokploy.com/install.sh | bash
|
||||
curl -sSL https://dokploy.com/install.sh | sh
|
||||
```
|
||||
|
||||
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -32,8 +32,6 @@ describe("Host rule format regression tests", () => {
|
||||
previewDeploymentId: "",
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
customEntrypoint: null,
|
||||
middlewares: null,
|
||||
};
|
||||
|
||||
describe("Host rule format validation", () => {
|
||||
|
||||
@@ -7,7 +7,6 @@ describe("createDomainLabels", () => {
|
||||
const baseDomain: Domain = {
|
||||
host: "example.com",
|
||||
port: 8080,
|
||||
customEntrypoint: null,
|
||||
https: false,
|
||||
uniqueConfigKey: 1,
|
||||
customCertResolver: null,
|
||||
@@ -22,7 +21,6 @@ describe("createDomainLabels", () => {
|
||||
previewDeploymentId: "",
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
};
|
||||
|
||||
it("should create basic labels for web entrypoint", async () => {
|
||||
@@ -173,12 +171,12 @@ describe("createDomainLabels", () => {
|
||||
"websecure",
|
||||
);
|
||||
|
||||
// Web entrypoint with HTTPS should only have redirect
|
||||
// Web entrypoint should have both middlewares with redirect first
|
||||
expect(webLabels).toContain(
|
||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,addprefix-test-app-1",
|
||||
);
|
||||
|
||||
// Websecure should have the addprefix middleware
|
||||
// Websecure should only have the addprefix middleware
|
||||
expect(websecureLabels).toContain(
|
||||
"traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1",
|
||||
);
|
||||
@@ -210,9 +208,9 @@ describe("createDomainLabels", () => {
|
||||
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
||||
);
|
||||
|
||||
// Web router with HTTPS should only have redirect
|
||||
// Should have middlewares in correct order: redirect, stripprefix, addprefix
|
||||
expect(webLabels).toContain(
|
||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,stripprefix-test-app-1,addprefix-test-app-1",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -242,259 +240,4 @@ describe("createDomainLabels", () => {
|
||||
"traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("should add single custom middleware to router", async () => {
|
||||
const customMiddlewareDomain = {
|
||||
...baseDomain,
|
||||
middlewares: ["auth@file"],
|
||||
};
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
customMiddlewareDomain,
|
||||
"web",
|
||||
);
|
||||
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-web.middlewares=auth@file",
|
||||
);
|
||||
});
|
||||
|
||||
it("should add multiple custom middlewares to router", async () => {
|
||||
const customMiddlewareDomain = {
|
||||
...baseDomain,
|
||||
middlewares: ["auth@file", "rate-limit@file"],
|
||||
};
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
customMiddlewareDomain,
|
||||
"web",
|
||||
);
|
||||
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-web.middlewares=auth@file,rate-limit@file",
|
||||
);
|
||||
});
|
||||
|
||||
it("should only have redirect on web router when HTTPS is enabled with custom middlewares", async () => {
|
||||
const combinedDomain = {
|
||||
...baseDomain,
|
||||
https: true,
|
||||
middlewares: ["auth@file"],
|
||||
};
|
||||
const labels = await createDomainLabels(appName, combinedDomain, "web");
|
||||
|
||||
// Web router with HTTPS should only redirect, custom middlewares go on websecure
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
||||
);
|
||||
expect(labels).not.toContain("auth@file");
|
||||
});
|
||||
|
||||
it("should combine custom middlewares with stripPath middleware (no HTTPS)", async () => {
|
||||
const combinedDomain = {
|
||||
...baseDomain,
|
||||
path: "/api",
|
||||
stripPath: true,
|
||||
middlewares: ["auth@file"],
|
||||
};
|
||||
const labels = await createDomainLabels(appName, combinedDomain, "web");
|
||||
|
||||
// stripprefix should come before custom middleware
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-web.middlewares=stripprefix-test-app-1,auth@file",
|
||||
);
|
||||
});
|
||||
|
||||
it("should only have redirect on web router even with all built-in middlewares and custom middlewares", async () => {
|
||||
const fullDomain = {
|
||||
...baseDomain,
|
||||
https: true,
|
||||
path: "/api",
|
||||
stripPath: true,
|
||||
internalPath: "/hello",
|
||||
middlewares: ["auth@file", "rate-limit@file"],
|
||||
};
|
||||
const webLabels = await createDomainLabels(appName, fullDomain, "web");
|
||||
|
||||
// Web router with HTTPS should only redirect
|
||||
expect(webLabels).toContain(
|
||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
||||
);
|
||||
// Middleware definitions should still be present (Traefik needs them registered)
|
||||
expect(webLabels).toContain(
|
||||
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
|
||||
);
|
||||
expect(webLabels).toContain(
|
||||
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
||||
);
|
||||
// But they should NOT be attached to the router
|
||||
expect(webLabels).not.toContain("stripprefix-test-app-1,");
|
||||
expect(webLabels).not.toContain("auth@file");
|
||||
expect(webLabels).not.toContain("rate-limit@file");
|
||||
});
|
||||
|
||||
it("should include custom middlewares on websecure entrypoint", async () => {
|
||||
const customMiddlewareDomain = {
|
||||
...baseDomain,
|
||||
https: true,
|
||||
middlewares: ["auth@file"],
|
||||
};
|
||||
const websecureLabels = await createDomainLabels(
|
||||
appName,
|
||||
customMiddlewareDomain,
|
||||
"websecure",
|
||||
);
|
||||
|
||||
// Websecure should have custom middleware but not redirect-to-https
|
||||
expect(websecureLabels).toContain(
|
||||
"traefik.http.routers.test-app-1-websecure.middlewares=auth@file",
|
||||
);
|
||||
expect(websecureLabels).not.toContain("redirect-to-https");
|
||||
});
|
||||
|
||||
it("should NOT include custom middlewares on web router when HTTPS is enabled (only redirect)", async () => {
|
||||
const domain = {
|
||||
...baseDomain,
|
||||
https: true,
|
||||
middlewares: ["rate-limit@file", "auth@file"],
|
||||
};
|
||||
const webLabels = await createDomainLabels(appName, domain, "web");
|
||||
|
||||
// Web router with HTTPS should ONLY have redirect, not custom middlewares
|
||||
expect(webLabels).toContain(
|
||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
||||
);
|
||||
expect(webLabels).not.toContain("rate-limit@file");
|
||||
expect(webLabels).not.toContain("auth@file");
|
||||
});
|
||||
|
||||
it("should create basic labels for custom entrypoint", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
{ ...baseDomain, customEntrypoint: "custom" },
|
||||
"custom",
|
||||
);
|
||||
expect(labels).toEqual([
|
||||
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
|
||||
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
|
||||
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
|
||||
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should create https labels for custom entrypoint", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
{
|
||||
...baseDomain,
|
||||
https: true,
|
||||
customEntrypoint: "custom",
|
||||
certificateType: "letsencrypt",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
expect(labels).toEqual([
|
||||
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
|
||||
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
|
||||
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
|
||||
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
|
||||
"traefik.http.routers.test-app-1-custom.tls.certresolver=letsencrypt",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should add stripPath middleware for custom entrypoint", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
{
|
||||
...baseDomain,
|
||||
customEntrypoint: "custom",
|
||||
path: "/api",
|
||||
stripPath: true,
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(labels).toContain(
|
||||
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
|
||||
);
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("should add internalPath middleware for custom entrypoint", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
{
|
||||
...baseDomain,
|
||||
customEntrypoint: "custom",
|
||||
internalPath: "/hello",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(labels).toContain(
|
||||
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
||||
);
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-custom.middlewares=addprefix-test-app-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("should add path prefix in rule for custom entrypoint", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
{
|
||||
...baseDomain,
|
||||
customEntrypoint: "custom",
|
||||
path: "/api",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`) && PathPrefix(`/api`)",
|
||||
);
|
||||
});
|
||||
|
||||
it("should combine all middlewares for custom entrypoint", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
{
|
||||
...baseDomain,
|
||||
customEntrypoint: "custom",
|
||||
path: "/api",
|
||||
stripPath: true,
|
||||
internalPath: "/hello",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(labels).toContain(
|
||||
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
|
||||
);
|
||||
expect(labels).toContain(
|
||||
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
||||
);
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not add redirect-to-https for custom entrypoint even with https", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
{
|
||||
...baseDomain,
|
||||
customEntrypoint: "custom",
|
||||
https: true,
|
||||
certificateType: "letsencrypt",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
const middlewareLabel = labels.find((l) => l.includes(".middlewares="));
|
||||
// Should not contain redirect-to-https since there's only one router
|
||||
expect(middlewareLabel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,7 +292,7 @@ networks:
|
||||
dokploy-network:
|
||||
`;
|
||||
|
||||
test("It shouldn't add suffix to dokploy-network", () => {
|
||||
test("It shoudn't add suffix to dokploy-network", () => {
|
||||
const composeData = parse(composeFile7) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -195,7 +195,7 @@ services:
|
||||
- dokploy-network
|
||||
`;
|
||||
|
||||
test("It shouldn't add suffix to dokploy-network in services", () => {
|
||||
test("It shoudn't add suffix to dokploy-network in services", () => {
|
||||
const composeData = parse(composeFile7) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
@@ -241,10 +241,10 @@ services:
|
||||
dokploy-network:
|
||||
aliases:
|
||||
- apid
|
||||
|
||||
|
||||
`;
|
||||
|
||||
test("It shouldn't add suffix to dokploy-network in services multiples cases", () => {
|
||||
test("It shoudn't add suffix to dokploy-network in services multiples cases", () => {
|
||||
const composeData = parse(composeFile8) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
@@ -415,24 +415,5 @@ describe("Docker Image Name and Tag Extraction", () => {
|
||||
expect(extractImageTag("my-image:123")).toBe("123");
|
||||
expect(extractImageTag("my-image:1")).toBe("1");
|
||||
});
|
||||
|
||||
it("should return 'latest' for registry with port but no tag", () => {
|
||||
expect(extractImageTag("registry.example.com:5000/myimage")).toBe(
|
||||
"latest",
|
||||
);
|
||||
expect(extractImageTag("registry:5000/fedora/httpd")).toBe("latest");
|
||||
expect(extractImageTag("localhost:5000/myapp")).toBe("latest");
|
||||
expect(extractImageTag("my-registry.io:443/org/app")).toBe("latest");
|
||||
});
|
||||
|
||||
it("should extract tag from registry with port and tag", () => {
|
||||
expect(extractImageTag("registry:5000/image:tag")).toBe("tag");
|
||||
expect(extractImageTag("registry.example.com:5000/myimage:v2.0")).toBe(
|
||||
"v2.0",
|
||||
);
|
||||
expect(extractImageTag("localhost:5000/app:sha-abc123")).toBe(
|
||||
"sha-abc123",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,7 +120,6 @@ const baseApp: ApplicationNested = {
|
||||
environmentId: "",
|
||||
enabled: null,
|
||||
env: null,
|
||||
icon: null,
|
||||
healthCheckSwarm: null,
|
||||
labelsSwarm: null,
|
||||
memoryLimit: null,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getEnvironmentVariablesObject } from "@dokploy/server/index";
|
||||
import { getEnviromentVariablesObject } from "@dokploy/server/index";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const projectEnv = `
|
||||
@@ -15,7 +15,7 @@ DATABASE_NAME=dev_database
|
||||
SECRET_KEY=env-secret-123
|
||||
`;
|
||||
|
||||
describe("getEnvironmentVariablesObject with environment variables (Stack compose)", () => {
|
||||
describe("getEnviromentVariablesObject with environment variables (Stack compose)", () => {
|
||||
it("resolves environment variables correctly for Stack compose", () => {
|
||||
const serviceEnv = `
|
||||
FOO=\${{environment.NODE_ENV}}
|
||||
@@ -23,7 +23,7 @@ BAR=\${{environment.API_URL}}
|
||||
BAZ=test
|
||||
`;
|
||||
|
||||
const result = getEnvironmentVariablesObject(
|
||||
const result = getEnviromentVariablesObject(
|
||||
serviceEnv,
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
@@ -45,7 +45,7 @@ DATABASE_URL=\${{project.DATABASE_URL}}
|
||||
SERVICE_PORT=4000
|
||||
`;
|
||||
|
||||
const result = getEnvironmentVariablesObject(
|
||||
const result = getEnviromentVariablesObject(
|
||||
serviceEnv,
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
@@ -72,7 +72,7 @@ PASSWORD=secret123
|
||||
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
|
||||
`;
|
||||
|
||||
const result = getEnvironmentVariablesObject(serviceEnv, "", multiRefEnv);
|
||||
const result = getEnviromentVariablesObject(serviceEnv, "", multiRefEnv);
|
||||
|
||||
expect(result).toEqual({
|
||||
DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
|
||||
@@ -85,7 +85,7 @@ UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}}
|
||||
`;
|
||||
|
||||
expect(() =>
|
||||
getEnvironmentVariablesObject(serviceWithUndefined, "", environmentEnv),
|
||||
getEnviromentVariablesObject(serviceWithUndefined, "", environmentEnv),
|
||||
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ NODE_ENV=production
|
||||
API_URL=\${{environment.API_URL}}
|
||||
`;
|
||||
|
||||
const result = getEnvironmentVariablesObject(
|
||||
const result = getEnviromentVariablesObject(
|
||||
serviceOverrideEnv,
|
||||
"",
|
||||
environmentEnv,
|
||||
@@ -115,7 +115,7 @@ SERVICE_NAME=my-service
|
||||
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
|
||||
`;
|
||||
|
||||
const result = getEnvironmentVariablesObject(
|
||||
const result = getEnviromentVariablesObject(
|
||||
complexServiceEnv,
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
@@ -150,7 +150,7 @@ ENV_VAR=\${{environment.API_URL}}
|
||||
DB_NAME=\${{environment.DATABASE_NAME}}
|
||||
`;
|
||||
|
||||
const result = getEnvironmentVariablesObject(
|
||||
const result = getEnviromentVariablesObject(
|
||||
serviceWithConflicts,
|
||||
conflictingProjectEnv,
|
||||
conflictingEnvironmentEnv,
|
||||
@@ -170,7 +170,7 @@ SERVICE_VAR=test
|
||||
PROJECT_VAR=\${{project.ENVIRONMENT}}
|
||||
`;
|
||||
|
||||
const result = getEnvironmentVariablesObject(
|
||||
const result = getEnviromentVariablesObject(
|
||||
serviceWithEmpty,
|
||||
projectEnv,
|
||||
"",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
enterpriseOnlyResources,
|
||||
statements,
|
||||
} from "@dokploy/server/lib/access-control";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const FREE_TIER_RESOURCES = [
|
||||
"organization",
|
||||
|
||||
@@ -57,7 +57,7 @@ const createApplication = (
|
||||
env: null,
|
||||
},
|
||||
replicas: 1,
|
||||
stopGracePeriodSwarm: 0,
|
||||
stopGracePeriodSwarm: 0n,
|
||||
ulimitsSwarm: null,
|
||||
serverId: "server-id",
|
||||
...overrides,
|
||||
@@ -76,8 +76,8 @@ describe("mechanizeDockerContainer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes stopGracePeriodSwarm as a number and keeps zero values", async () => {
|
||||
const application = createApplication({ stopGracePeriodSwarm: 0 });
|
||||
it("converts bigint stopGracePeriodSwarm to a number and keeps zero values", async () => {
|
||||
const application = createApplication({ stopGracePeriodSwarm: 0n });
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
|
||||
@@ -95,7 +95,6 @@ const baseApp: ApplicationNested = {
|
||||
dropBuildPath: null,
|
||||
enabled: null,
|
||||
env: null,
|
||||
icon: null,
|
||||
healthCheckSwarm: null,
|
||||
labelsSwarm: null,
|
||||
memoryLimit: null,
|
||||
@@ -138,7 +137,6 @@ const baseDomain: Domain = {
|
||||
https: false,
|
||||
path: null,
|
||||
port: null,
|
||||
customEntrypoint: null,
|
||||
serviceName: "",
|
||||
composeId: "",
|
||||
customCertResolver: null,
|
||||
@@ -147,7 +145,6 @@ const baseDomain: Domain = {
|
||||
previewDeploymentId: "",
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
};
|
||||
|
||||
const baseRedirect: Redirect = {
|
||||
@@ -267,80 +264,6 @@ test("Websecure entrypoint on https domain with redirect", async () => {
|
||||
expect(router.middlewares).toContain("redirect-test-1");
|
||||
});
|
||||
|
||||
/** Custom Middlewares */
|
||||
|
||||
test("Web entrypoint with single custom middleware", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, middlewares: ["auth@file"] },
|
||||
"web",
|
||||
);
|
||||
|
||||
expect(router.middlewares).toContain("auth@file");
|
||||
});
|
||||
|
||||
test("Web entrypoint with multiple custom middlewares", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, middlewares: ["auth@file", "rate-limit@file"] },
|
||||
"web",
|
||||
);
|
||||
|
||||
expect(router.middlewares).toContain("auth@file");
|
||||
expect(router.middlewares).toContain("rate-limit@file");
|
||||
});
|
||||
|
||||
test("Web entrypoint on https domain with custom middleware", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
|
||||
"web",
|
||||
);
|
||||
|
||||
// Should only have HTTPS redirect - custom middleware applies on websecure
|
||||
expect(router.middlewares).toContain("redirect-to-https");
|
||||
expect(router.middlewares).not.toContain("auth@file");
|
||||
});
|
||||
|
||||
test("Websecure entrypoint with custom middleware", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
|
||||
"websecure",
|
||||
);
|
||||
|
||||
// Should have custom middleware but not HTTPS redirect
|
||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
||||
expect(router.middlewares).toContain("auth@file");
|
||||
});
|
||||
|
||||
test("Web entrypoint with redirect and custom middleware", async () => {
|
||||
const router = await createRouterConfig(
|
||||
{
|
||||
...baseApp,
|
||||
appName: "test",
|
||||
redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }],
|
||||
},
|
||||
{ ...baseDomain, middlewares: ["auth@file"] },
|
||||
"web",
|
||||
);
|
||||
|
||||
// Should have both redirect middleware and custom middleware
|
||||
expect(router.middlewares).toContain("redirect-test-1");
|
||||
expect(router.middlewares).toContain("auth@file");
|
||||
});
|
||||
|
||||
test("Web entrypoint with empty middlewares array", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, https: false, middlewares: [] },
|
||||
"web",
|
||||
);
|
||||
|
||||
// Should behave same as no middlewares - no redirect for http
|
||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
||||
});
|
||||
|
||||
/** Certificates */
|
||||
|
||||
test("CertificateType on websecure entrypoint", async () => {
|
||||
@@ -353,130 +276,6 @@ test("CertificateType on websecure entrypoint", async () => {
|
||||
expect(router.tls?.certResolver).toBe("letsencrypt");
|
||||
});
|
||||
|
||||
test("Custom entrypoint on http domain", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, https: false, customEntrypoint: "custom" },
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(router.entryPoints).toEqual(["custom"]);
|
||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
||||
expect(router.tls).toBeUndefined();
|
||||
});
|
||||
|
||||
test("Custom entrypoint on https domain", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{
|
||||
...baseDomain,
|
||||
https: true,
|
||||
customEntrypoint: "custom",
|
||||
certificateType: "letsencrypt",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(router.entryPoints).toEqual(["custom"]);
|
||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
||||
expect(router.tls?.certResolver).toBe("letsencrypt");
|
||||
});
|
||||
|
||||
test("Custom entrypoint with path includes PathPrefix in rule", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, customEntrypoint: "custom", path: "/api" },
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(router.rule).toContain("PathPrefix(`/api`)");
|
||||
expect(router.entryPoints).toEqual(["custom"]);
|
||||
});
|
||||
|
||||
test("Custom entrypoint with stripPath adds stripprefix middleware", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{
|
||||
...baseDomain,
|
||||
customEntrypoint: "custom",
|
||||
path: "/api",
|
||||
stripPath: true,
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(router.middlewares).toContain("stripprefix--1");
|
||||
expect(router.entryPoints).toEqual(["custom"]);
|
||||
});
|
||||
|
||||
test("Custom entrypoint with internalPath adds addprefix middleware", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{
|
||||
...baseDomain,
|
||||
customEntrypoint: "custom",
|
||||
internalPath: "/hello",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(router.middlewares).toContain("addprefix--1");
|
||||
expect(router.entryPoints).toEqual(["custom"]);
|
||||
});
|
||||
|
||||
test("stripPath and internalPath together: stripprefix must come before addprefix", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{
|
||||
...baseDomain,
|
||||
path: "/public",
|
||||
stripPath: true,
|
||||
internalPath: "/app/v2",
|
||||
},
|
||||
"web",
|
||||
);
|
||||
|
||||
const stripIndex = router.middlewares?.indexOf("stripprefix--1") ?? -1;
|
||||
const addIndex = router.middlewares?.indexOf("addprefix--1") ?? -1;
|
||||
|
||||
expect(stripIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(addIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(stripIndex).toBeLessThan(addIndex);
|
||||
});
|
||||
|
||||
test("Custom entrypoint with https and custom cert resolver", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{
|
||||
...baseDomain,
|
||||
https: true,
|
||||
customEntrypoint: "custom",
|
||||
certificateType: "custom",
|
||||
customCertResolver: "myresolver",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(router.entryPoints).toEqual(["custom"]);
|
||||
expect(router.tls?.certResolver).toBe("myresolver");
|
||||
});
|
||||
|
||||
test("Custom entrypoint without https should not have tls", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{
|
||||
...baseDomain,
|
||||
https: false,
|
||||
customEntrypoint: "custom",
|
||||
certificateType: "letsencrypt",
|
||||
},
|
||||
"custom",
|
||||
);
|
||||
|
||||
expect(router.entryPoints).toEqual(["custom"]);
|
||||
expect(router.tls).toBeUndefined();
|
||||
});
|
||||
|
||||
/** IDN/Punycode */
|
||||
|
||||
test("Internationalized domain name is converted to punycode", async () => {
|
||||
|
||||
@@ -1,629 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import type { ChatContext } from "@dokploy/server/utils/ai/chat-tools";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import {
|
||||
Bot,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
Send,
|
||||
Trash2,
|
||||
Wrench,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
function useChatContext(): ChatContext {
|
||||
const router = useRouter();
|
||||
const { query, pathname } = router;
|
||||
|
||||
return useMemo(() => {
|
||||
const projectId =
|
||||
typeof query.projectId === "string" ? query.projectId : undefined;
|
||||
const environmentId =
|
||||
typeof query.environmentId === "string"
|
||||
? query.environmentId
|
||||
: undefined;
|
||||
const serverId =
|
||||
typeof query.serverId === "string" ? query.serverId : undefined;
|
||||
|
||||
const serviceParams = [
|
||||
{ key: "applicationId", type: "application" },
|
||||
{ key: "composeId", type: "compose" },
|
||||
{ key: "postgresId", type: "postgres" },
|
||||
{ key: "mysqlId", type: "mysql" },
|
||||
{ key: "redisId", type: "redis" },
|
||||
{ key: "mongoId", type: "mongo" },
|
||||
{ key: "mariadbId", type: "mariadb" },
|
||||
{ key: "libsqlId", type: "libsql" },
|
||||
] as const;
|
||||
|
||||
for (const { key, type } of serviceParams) {
|
||||
if (query[key] && typeof query[key] === "string") {
|
||||
return {
|
||||
type,
|
||||
id: query[key] as string,
|
||||
projectId,
|
||||
environmentId,
|
||||
serverId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (query.projectId && typeof query.projectId === "string") {
|
||||
return {
|
||||
type: "project" as const,
|
||||
id: query.projectId,
|
||||
projectId,
|
||||
environmentId,
|
||||
serverId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "general" as const,
|
||||
id: "",
|
||||
projectId,
|
||||
environmentId,
|
||||
serverId,
|
||||
};
|
||||
}, [
|
||||
query.applicationId,
|
||||
query.composeId,
|
||||
query.postgresId,
|
||||
query.mysqlId,
|
||||
query.redisId,
|
||||
query.mongoId,
|
||||
query.mariadbId,
|
||||
query.libsqlId,
|
||||
query.projectId,
|
||||
query.environmentId,
|
||||
query.serverId,
|
||||
pathname,
|
||||
]);
|
||||
}
|
||||
|
||||
export function ChatPanel() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [aiId, setAiId] = useState<string>("");
|
||||
const [input, setInput] = useState("");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const context = useChatContext();
|
||||
const aiIdRef = useRef(aiId);
|
||||
const contextRef = useRef(context);
|
||||
aiIdRef.current = aiId;
|
||||
contextRef.current = context;
|
||||
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery(undefined, {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, {
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: !isCloud,
|
||||
});
|
||||
|
||||
const enabledProviders = providers ?? [];
|
||||
|
||||
const STORAGE_KEY = "dokploy-chat-messages";
|
||||
const restoredRef = useRef(false);
|
||||
|
||||
const { messages, sendMessage, status, setMessages, addToolApprovalResponse } = useChat({
|
||||
id: "dokploy-chat",
|
||||
transport: new DefaultChatTransport({
|
||||
api: "/api/ai/chat",
|
||||
body: () => ({
|
||||
...(isCloud ? {} : { aiId: aiIdRef.current }),
|
||||
context: contextRef.current,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const isLoading = status === "streaming" || status === "submitted";
|
||||
|
||||
// Restore messages from localStorage on mount
|
||||
useEffect(() => {
|
||||
if (restoredRef.current) return;
|
||||
restoredRef.current = true;
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
setMessages(parsed);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [setMessages]);
|
||||
|
||||
// Persist messages to localStorage
|
||||
useEffect(() => {
|
||||
if (!restoredRef.current) return;
|
||||
if (messages.length > 0) {
|
||||
try {
|
||||
const toStore = messages.slice(-50);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore));
|
||||
} catch {
|
||||
// localStorage full or unavailable — ignore
|
||||
}
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCloud && !aiId && enabledProviders.length > 0 && enabledProviders[0]) {
|
||||
setAiId(enabledProviders[0].aiId);
|
||||
}
|
||||
}, [enabledProviders, aiId, isCloud]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, status]);
|
||||
|
||||
if (!isCloud && enabledProviders.length === 0) return null;
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || isLoading) return;
|
||||
if (!isCloud && !aiId) return;
|
||||
sendMessage({ text: input });
|
||||
setInput("");
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
const contextLabel =
|
||||
context.type === "general" ? "General" : context.type;
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setOpen(true)}
|
||||
variant="outline"
|
||||
className="fixed bottom-6 right-6 z-50 h-11 w-11 rounded-full shadow-md border"
|
||||
size="icon"
|
||||
>
|
||||
<Bot className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-full sm:w-[480px] p-0 flex flex-col border-l outline-none"
|
||||
>
|
||||
<SheetHeader className="px-4 py-3 border-b shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4 text-muted-foreground" />
|
||||
<SheetTitle className="text-sm font-medium">
|
||||
{isCloud ? "Dokploy Agent" : "AI Assistant"}
|
||||
</SheetTitle>
|
||||
{isLoading && (
|
||||
<span className="text-xs text-muted-foreground animate-pulse">
|
||||
working...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SheetDescription className="sr-only">
|
||||
Chat with AI to manage your infrastructure
|
||||
</SheetDescription>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{!isCloud && (
|
||||
<Select value={aiId} onValueChange={setAiId}>
|
||||
<SelectTrigger className="h-8 text-xs flex-1">
|
||||
<SelectValue placeholder="Select provider..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enabledProviders.map((p) => (
|
||||
<SelectItem key={p.aiId} value={p.aiId}>
|
||||
{p.name} ({p.model})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs shrink-0 capitalize font-normal"
|
||||
>
|
||||
{contextLabel}
|
||||
</Badge>
|
||||
{messages.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => { setMessages([]); localStorage.removeItem(STORAGE_KEY); }}
|
||||
title="Clear chat"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto p-4 space-y-3"
|
||||
>
|
||||
{messages.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
|
||||
<Bot className="h-8 w-8 opacity-30" />
|
||||
<p className="text-sm text-center">
|
||||
Ask me anything about your{" "}
|
||||
{context.type === "general"
|
||||
? "infrastructure"
|
||||
: context.type}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5 justify-center">
|
||||
{(context.type === "application"
|
||||
? [
|
||||
"What's the status of this app?",
|
||||
"Why did the last build fail?",
|
||||
"Show me recent deployments",
|
||||
"Redeploy this app",
|
||||
]
|
||||
: context.type === "compose"
|
||||
? [
|
||||
"Show compose service status",
|
||||
"Why did the last deploy fail?",
|
||||
"Show me the domains",
|
||||
"Redeploy this service",
|
||||
]
|
||||
: context.type === "postgres" ||
|
||||
context.type === "mysql" ||
|
||||
context.type === "redis" ||
|
||||
context.type === "mongo" ||
|
||||
context.type === "mariadb" ||
|
||||
context.type === "libsql"
|
||||
? [
|
||||
`Show ${context.type} status`,
|
||||
"What's the connection info?",
|
||||
"Show recent deployments",
|
||||
"Restart this database",
|
||||
]
|
||||
: context.type === "project"
|
||||
? [
|
||||
"How many services do I have?",
|
||||
"Show me all environments",
|
||||
"Which services are failing?",
|
||||
]
|
||||
: [
|
||||
"List all my projects",
|
||||
"Show project overview",
|
||||
"What servers do I have?",
|
||||
]
|
||||
).map((suggestion) => (
|
||||
<Button
|
||||
key={suggestion}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7 font-normal"
|
||||
onClick={() => setInput(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((message) => {
|
||||
if (message.role === "user") {
|
||||
return (
|
||||
<div key={message.id} className="flex justify-end">
|
||||
<div className="max-w-[85%] rounded-lg px-3 py-2 text-sm bg-muted">
|
||||
<p className="whitespace-pre-wrap">
|
||||
{message.parts
|
||||
.filter(
|
||||
(p): p is { type: "text"; text: string } =>
|
||||
p.type === "text",
|
||||
)
|
||||
.map((p) => p.text)
|
||||
.join("")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={message.id} className="flex justify-start">
|
||||
<div className="max-w-[90%] space-y-2">
|
||||
{message.parts.map((part, i) => {
|
||||
if (
|
||||
part.type === "text" &&
|
||||
(part as { text?: string }).text?.trim()
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
key={`text-${message.id}-${i}`}
|
||||
className="rounded-lg border px-3 py-2 text-sm prose prose-sm dark:prose-invert max-w-none break-words"
|
||||
>
|
||||
<ReactMarkdown>
|
||||
{(part as { text: string }).text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type === "dynamic-tool") {
|
||||
return (
|
||||
<div
|
||||
key={part.toolCallId}
|
||||
className="rounded-lg border px-3 py-2"
|
||||
>
|
||||
<ToolCallDisplay
|
||||
toolCallId={part.toolCallId}
|
||||
toolName={part.toolName}
|
||||
state={part.state}
|
||||
input={(part as any).input}
|
||||
output={
|
||||
part.state === "output-available"
|
||||
? part.output
|
||||
: undefined
|
||||
}
|
||||
onApprove={(id) =>
|
||||
addToolApprovalResponse({
|
||||
id,
|
||||
approved: true,
|
||||
})
|
||||
}
|
||||
onDeny={(id) =>
|
||||
addToolApprovalResponse({
|
||||
id,
|
||||
approved: false,
|
||||
reason: "User denied",
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type === "reasoning") {
|
||||
return (
|
||||
<Collapsible key={`reasoning-${message.id}-${i}`}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Bot className="h-3 w-3" />
|
||||
<span>Thinking...</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-1 rounded-lg border px-3 py-2 text-xs text-muted-foreground italic">
|
||||
{(part as any).text ||
|
||||
(part as any).reasoning}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{isLoading && lastMessage?.role === "user" && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-lg border px-3 py-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Investigating...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t p-3 shrink-0 flex gap-2">
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={
|
||||
!isCloud && !aiId
|
||||
? "Select a provider first..."
|
||||
: "Ask anything..."
|
||||
}
|
||||
disabled={(!isCloud && !aiId) || isLoading}
|
||||
className="min-h-[40px] max-h-[120px] resize-none text-sm"
|
||||
rows={1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
disabled={
|
||||
(!isCloud && !aiId) || !input.trim() || isLoading
|
||||
}
|
||||
className="shrink-0 h-10 w-10"
|
||||
onClick={handleSend}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallDisplay({
|
||||
toolCallId,
|
||||
toolName,
|
||||
state,
|
||||
input,
|
||||
output,
|
||||
onApprove,
|
||||
onDeny,
|
||||
}: {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
state: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
onApprove?: (id: string) => void;
|
||||
onDeny?: (id: string) => void;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isRunning =
|
||||
state === "input-streaming" || state === "input-available";
|
||||
const isDone = state === "output-available";
|
||||
const isError = state === "output-error";
|
||||
const needsApproval = state === "requires-approval";
|
||||
|
||||
const outputText = output
|
||||
? typeof output === "string"
|
||||
? output
|
||||
: JSON.stringify(output, null, 2)
|
||||
: null;
|
||||
|
||||
// Extract operationId and params from input
|
||||
const inputData = input as { operationId?: string; params?: Record<string, unknown> } | undefined;
|
||||
const operationId = inputData?.operationId;
|
||||
const params = inputData?.params;
|
||||
|
||||
// Format: "compose-one" → "compose → one"
|
||||
const displayLabel = operationId
|
||||
? operationId.replace("-", " → ")
|
||||
: toolName;
|
||||
|
||||
// Determine HTTP method hint from operationId
|
||||
const isReadOp = operationId?.match(/^(.*-)?(one|all|get|list|read|search|by)/i);
|
||||
|
||||
const StatusIcon = isRunning
|
||||
? () => <Loader2 className="h-3.5 w-3.5 animate-spin text-blue-500 shrink-0" />
|
||||
: isDone
|
||||
? () => <Check className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||
: isError
|
||||
? () => <X className="h-3.5 w-3.5 text-red-500 shrink-0" />
|
||||
: () => <Wrench className="h-3.5 w-3.5 text-muted-foreground shrink-0" />;
|
||||
|
||||
if (needsApproval) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Wrench className="h-3.5 w-3.5 text-yellow-500 shrink-0" />
|
||||
<code className="font-mono text-xs font-medium">{displayLabel}</code>
|
||||
<Badge variant="outline" className="text-[10px] px-1 py-0 h-4 font-normal">
|
||||
write
|
||||
</Badge>
|
||||
</div>
|
||||
{params && Object.keys(params).length > 0 && (
|
||||
<div className="ml-5.5 flex flex-wrap gap-1">
|
||||
{Object.entries(params).map(([key, value]) => (
|
||||
<span key={key} className="text-[10px] bg-muted px-1.5 py-0.5 rounded font-mono">
|
||||
{key}={typeof value === "string" ? `"${value}"` : String(value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-1.5 ml-5.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs px-2"
|
||||
onClick={() => onApprove?.(toolCallId)}
|
||||
>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs px-2"
|
||||
onClick={() => onDeny?.(toolCallId)}
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
Deny
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-xs w-full hover:bg-muted/50 rounded -mx-1 px-1 py-0.5 transition-colors"
|
||||
>
|
||||
<StatusIcon />
|
||||
<code className="font-mono text-xs font-medium">{displayLabel}</code>
|
||||
{isReadOp && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4 font-normal">
|
||||
read
|
||||
</Badge>
|
||||
)}
|
||||
{params && Object.keys(params).length > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{Object.entries(params)
|
||||
.slice(0, 3)
|
||||
.map(([k, v]) => `${k}=${typeof v === "string" ? `"${String(v).slice(0, 20)}"` : String(v)}`)
|
||||
.join(", ")}
|
||||
{Object.keys(params).length > 3 ? ` +${Object.keys(params).length - 3}` : ""}
|
||||
</span>
|
||||
)}
|
||||
{(outputText || isRunning) && (
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 ml-auto text-muted-foreground transition-transform shrink-0 ${isOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
{outputText && (
|
||||
<CollapsibleContent>
|
||||
<pre className="mt-1 ml-5.5 p-2 bg-muted/50 rounded text-[10px] overflow-x-auto max-h-[200px] overflow-y-auto leading-tight whitespace-pre-wrap break-words">
|
||||
{outputText}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -110,13 +110,6 @@ const menuItems: MenuItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const hasStopGracePeriodSwarm = (
|
||||
value: unknown,
|
||||
): value is { stopGracePeriodSwarm: number | string | null } =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"stopGracePeriodSwarm" in value;
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type:
|
||||
|
||||
@@ -40,12 +40,12 @@ interface Props {
|
||||
type: "application" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis";
|
||||
}
|
||||
|
||||
const AddRedirectSchema = z.object({
|
||||
const AddRedirectchema = z.object({
|
||||
replicas: z.number().min(1, "Replicas must be at least 1"),
|
||||
registryId: z.string().optional(),
|
||||
});
|
||||
|
||||
type AddCommand = z.infer<typeof AddRedirectSchema>;
|
||||
type AddCommand = z.infer<typeof AddRedirectchema>;
|
||||
|
||||
export const ShowClusterSettings = ({ id, type }: Props) => {
|
||||
const queryMap = {
|
||||
@@ -87,7 +87,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
||||
: {}),
|
||||
replicas: data?.replicas || 1,
|
||||
},
|
||||
resolver: zodResolver(AddRedirectSchema),
|
||||
resolver: zodResolver(AddRedirectchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -16,7 +16,7 @@ import { api } from "@/utils/api";
|
||||
|
||||
const hasStopGracePeriodSwarm = (
|
||||
value: unknown,
|
||||
): value is { stopGracePeriodSwarm: number | string | null } =>
|
||||
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"stopGracePeriodSwarm" in value;
|
||||
@@ -68,7 +68,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||
|
||||
const form = useForm<any>({
|
||||
defaultValues: {
|
||||
value: null as number | null,
|
||||
value: null as bigint | null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -76,7 +76,11 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||
if (hasStopGracePeriodSwarm(data)) {
|
||||
const value = data.stopGracePeriodSwarm;
|
||||
const normalizedValue =
|
||||
value === null || value === undefined ? null : Number(value);
|
||||
value === null || value === undefined
|
||||
? null
|
||||
: typeof value === "bigint"
|
||||
? value
|
||||
: BigInt(value);
|
||||
form.reset({
|
||||
value: normalizedValue,
|
||||
});
|
||||
@@ -132,7 +136,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||
}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value ? Number(e.target.value) : null,
|
||||
e.target.value ? BigInt(e.target.value) : null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -37,13 +37,13 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const AddRedirectSchema = z.object({
|
||||
const AddRedirectchema = z.object({
|
||||
regex: z.string().min(1, "Regex required"),
|
||||
permanent: z.boolean().default(false),
|
||||
replacement: z.string().min(1, "Replacement required"),
|
||||
});
|
||||
|
||||
type AddRedirect = z.infer<typeof AddRedirectSchema>;
|
||||
type AddRedirect = z.infer<typeof AddRedirectchema>;
|
||||
|
||||
// Default presets
|
||||
const redirectPresets = [
|
||||
@@ -110,7 +110,7 @@ export const HandleRedirect = ({
|
||||
regex: "",
|
||||
replacement: "",
|
||||
},
|
||||
resolver: zodResolver(AddRedirectSchema),
|
||||
resolver: zodResolver(AddRedirectchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -149,7 +149,7 @@ export const HandleRedirect = ({
|
||||
|
||||
const onDialogToggle = (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
// commented for the moment because not resetting the form if accidentally closed the dialog can be considered as a feature instead of a bug
|
||||
// commented for the moment because not reseting the form if accidentally closed the dialog can be considered as a feature instead of a bug
|
||||
// setPresetSelected("");
|
||||
// form.reset();
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { Check, Copy, Loader2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AnalyzeLogs } from "@/components/dashboard/docker/logs/analyze-logs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -166,7 +165,6 @@ export const ShowDeployment = ({
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<AnalyzeLogs logs={filteredLogs} context="build" />
|
||||
|
||||
{serverId && (
|
||||
<div className="flex items-center space-x-2">
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
PenBoxIcon,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { RouterOutputs } from "@/utils/api";
|
||||
import type { ValidationStates } from "./show-domains";
|
||||
import { AddDomain } from "./handle-domain";
|
||||
import { DnsHelperModal } from "./dns-helper-modal";
|
||||
|
||||
export type Domain =
|
||||
| RouterOutputs["domain"]["byApplicationId"][0]
|
||||
| RouterOutputs["domain"]["byComposeId"][0];
|
||||
|
||||
interface ColumnsProps {
|
||||
id: string;
|
||||
type: "application" | "compose";
|
||||
validationStates: ValidationStates;
|
||||
handleValidateDomain: (host: string) => Promise<void>;
|
||||
handleDeleteDomain: (domainId: string) => Promise<void>;
|
||||
isDeleting: boolean;
|
||||
serverIp?: string;
|
||||
canCreateDomain: boolean;
|
||||
canDeleteDomain: boolean;
|
||||
}
|
||||
|
||||
export const createColumns = ({
|
||||
id,
|
||||
type,
|
||||
validationStates,
|
||||
handleValidateDomain,
|
||||
handleDeleteDomain,
|
||||
isDeleting,
|
||||
serverIp,
|
||||
canCreateDomain,
|
||||
canDeleteDomain,
|
||||
}: ColumnsProps): ColumnDef<Domain>[] => [
|
||||
...(type === "compose"
|
||||
? [
|
||||
{
|
||||
accessorKey: "serviceName",
|
||||
header: "Service",
|
||||
cell: ({ row }: { row: { getValue: (key: string) => unknown } }) => {
|
||||
const serviceName = row.getValue("serviceName") as string | null;
|
||||
if (!serviceName) return null;
|
||||
return (
|
||||
<Badge variant="outline">
|
||||
<Server className="size-3 mr-1" />
|
||||
{serviceName}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
} satisfies ColumnDef<Domain>,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
accessorKey: "host",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Host
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const domain = row.original;
|
||||
return (
|
||||
<Link
|
||||
className="flex items-center gap-2 font-medium hover:underline"
|
||||
target="_blank"
|
||||
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
||||
>
|
||||
{domain.host}
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "path",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Path
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const path = row.getValue("path") as string;
|
||||
return <div className="font-mono text-sm">{path || "/"}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "port",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Port
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const port = row.getValue("port") as number;
|
||||
return <Badge variant="secondary">{port}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "customEntrypoint",
|
||||
header: "Entrypoint",
|
||||
cell: ({ row }) => {
|
||||
const entrypoint = row.getValue("customEntrypoint") as string | null;
|
||||
if (!entrypoint) return <span className="text-muted-foreground">-</span>;
|
||||
return <div className="font-mono text-sm">{entrypoint}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "https",
|
||||
header: "Protocol",
|
||||
cell: ({ row }) => {
|
||||
const https = row.getValue("https") as boolean;
|
||||
return (
|
||||
<Badge variant={https ? "outline" : "secondary"}>
|
||||
{https ? "HTTPS" : "HTTP"}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "certificate",
|
||||
header: "Certificate",
|
||||
cell: ({ row }) => {
|
||||
const domain = row.original;
|
||||
const validationState = validationStates[domain.host];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{domain.certificateType && (
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{domain.certificateType}
|
||||
</Badge>
|
||||
)}
|
||||
{!domain.host.includes("traefik.me") && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
validationState?.isValid
|
||||
? "bg-green-500/10 text-green-500 cursor-pointer"
|
||||
: validationState?.error
|
||||
? "bg-red-500/10 text-red-500 cursor-pointer"
|
||||
: "bg-yellow-500/10 text-yellow-500 cursor-pointer"
|
||||
}
|
||||
onClick={() => handleValidateDomain(domain.host)}
|
||||
>
|
||||
{validationState?.isLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-3 mr-1 animate-spin" />
|
||||
Checking...
|
||||
</>
|
||||
) : validationState?.isValid ? (
|
||||
<>
|
||||
<CheckCircle2 className="size-3 mr-1" />
|
||||
{validationState.message && validationState.cdnProvider
|
||||
? `${validationState.cdnProvider}`
|
||||
: "Valid"}
|
||||
</>
|
||||
) : validationState?.error ? (
|
||||
<>
|
||||
<XCircle className="size-3 mr-1" />
|
||||
Invalid
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="size-3 mr-1" />
|
||||
Validate
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
{validationState?.error ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-medium text-red-500">Error:</p>
|
||||
<p>{validationState.error}</p>
|
||||
</div>
|
||||
) : (
|
||||
"Click to validate DNS configuration"
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Created
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const createdAt = row.getValue("createdAt") as string;
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{new Date(createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const domain = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{!domain.host.includes("traefik.me") && (
|
||||
<DnsHelperModal
|
||||
domain={{
|
||||
host: domain.host,
|
||||
https: domain.https,
|
||||
path: domain.path || undefined,
|
||||
}}
|
||||
serverIp={serverIp}
|
||||
/>
|
||||
)}
|
||||
{canCreateDomain && (
|
||||
<AddDomain id={id} type={type} domainId={domain.domainId}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10 h-8 w-8"
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
</AddDomain>
|
||||
)}
|
||||
{canDeleteDomain && (
|
||||
<DialogAction
|
||||
title="Delete Domain"
|
||||
description="Are you sure you want to delete this domain?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await handleDeleteDomain(domain.domainId);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 h-8 w-8"
|
||||
isLoading={isDeleting}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,12 +1,11 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { DatabaseZap, Dices, RefreshCw, X } from "lucide-react";
|
||||
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -62,14 +61,11 @@ export const domain = z
|
||||
.min(1, { message: "Port must be at least 1" })
|
||||
.max(65535, { message: "Port must be 65535 or below" })
|
||||
.optional(),
|
||||
useCustomEntrypoint: z.boolean(),
|
||||
customEntrypoint: z.string().optional(),
|
||||
https: z.boolean().optional(),
|
||||
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
||||
customCertResolver: z.string().optional(),
|
||||
serviceName: z.string().optional(),
|
||||
domainType: z.enum(["application", "compose", "preview"]).optional(),
|
||||
middlewares: z.array(z.string()).optional(),
|
||||
})
|
||||
.superRefine((input, ctx) => {
|
||||
if (input.https && !input.certificateType) {
|
||||
@@ -118,14 +114,6 @@ export const domain = z
|
||||
message: "Internal path must start with '/'",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.useCustomEntrypoint && !input.customEntrypoint) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["customEntrypoint"],
|
||||
message: "Custom entry point must be specified",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type Domain = z.infer<typeof domain>;
|
||||
@@ -208,20 +196,16 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
internalPath: undefined,
|
||||
stripPath: false,
|
||||
port: undefined,
|
||||
useCustomEntrypoint: false,
|
||||
customEntrypoint: undefined,
|
||||
https: false,
|
||||
certificateType: undefined,
|
||||
customCertResolver: undefined,
|
||||
serviceName: undefined,
|
||||
domainType: type,
|
||||
middlewares: [],
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const certificateType = form.watch("certificateType");
|
||||
const useCustomEntrypoint = form.watch("useCustomEntrypoint");
|
||||
const https = form.watch("https");
|
||||
const domainType = form.watch("domainType");
|
||||
const host = form.watch("host");
|
||||
@@ -236,13 +220,10 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
internalPath: data?.internalPath || undefined,
|
||||
stripPath: data?.stripPath || false,
|
||||
port: data?.port || undefined,
|
||||
useCustomEntrypoint: !!data.customEntrypoint,
|
||||
customEntrypoint: data.customEntrypoint || undefined,
|
||||
certificateType: data?.certificateType || undefined,
|
||||
customCertResolver: data?.customCertResolver || undefined,
|
||||
serviceName: data?.serviceName || undefined,
|
||||
domainType: data?.domainType || type,
|
||||
middlewares: data?.middlewares || [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -253,13 +234,10 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
internalPath: undefined,
|
||||
stripPath: false,
|
||||
port: undefined,
|
||||
useCustomEntrypoint: false,
|
||||
customEntrypoint: undefined,
|
||||
https: false,
|
||||
certificateType: undefined,
|
||||
customCertResolver: undefined,
|
||||
domainType: type,
|
||||
middlewares: [],
|
||||
});
|
||||
}
|
||||
}, [form, data, isPending, domainId]);
|
||||
@@ -290,7 +268,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
composeId: id,
|
||||
}),
|
||||
...data,
|
||||
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success(dictionary.success);
|
||||
@@ -658,55 +635,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="useCustomEntrypoint"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Custom Entrypoint</FormLabel>
|
||||
<FormDescription>
|
||||
Use custom entrypoint for domina
|
||||
<br />
|
||||
"web" and/or "websecure" is used by default.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
if (!checked) {
|
||||
form.setValue("customEntrypoint", undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{useCustomEntrypoint && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customEntrypoint"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Entrypoint Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter entrypoint name manually"
|
||||
{...field}
|
||||
className="w-full"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="https"
|
||||
@@ -797,88 +725,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="middlewares"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>Middlewares</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[300px]">
|
||||
<p>
|
||||
Add Traefik middleware references. Middlewares
|
||||
must be defined in your Traefik configuration.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{field.value?.map((name, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{name}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
onClick={() => {
|
||||
const newMiddlewares = [...(field.value || [])];
|
||||
newMiddlewares.splice(index, 1);
|
||||
form.setValue("middlewares", newMiddlewares);
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="e.g., rate-limit@file, auth@file"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget;
|
||||
const value = input.value.trim();
|
||||
if (value && !field.value?.includes(value)) {
|
||||
form.setValue("middlewares", [
|
||||
...(field.value || []),
|
||||
value,
|
||||
]);
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder="e.g., rate-limit@file, auth@file"]',
|
||||
) as HTMLInputElement;
|
||||
const value = input.value.trim();
|
||||
if (value && !field.value?.includes(value)) {
|
||||
form.setValue("middlewares", [
|
||||
...(field.value || []),
|
||||
value,
|
||||
]);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
import {
|
||||
type ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
LayoutGrid,
|
||||
LayoutList,
|
||||
Loader2,
|
||||
PenBoxIcon,
|
||||
RefreshCw,
|
||||
@@ -37,21 +23,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -59,7 +30,6 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { createColumns } from "./columns";
|
||||
import { DnsHelperModal } from "./dns-helper-modal";
|
||||
import { AddDomain } from "./handle-domain";
|
||||
|
||||
@@ -104,19 +74,6 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
const [validationStates, setValidationStates] = useState<ValidationStates>(
|
||||
{},
|
||||
);
|
||||
const [viewMode, setViewMode] = useState<"grid" | "table">(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return (
|
||||
(localStorage.getItem("domains-view-mode") as "grid" | "table") ??
|
||||
"grid"
|
||||
);
|
||||
}
|
||||
return "grid";
|
||||
});
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
|
||||
const {
|
||||
@@ -146,16 +103,6 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
const { mutateAsync: deleteDomain, isPending: isRemoving } =
|
||||
api.domain.delete.useMutation();
|
||||
|
||||
const handleDeleteDomain = async (domainId: string) => {
|
||||
try {
|
||||
await deleteDomain({ domainId });
|
||||
refetch();
|
||||
toast.success("Domain deleted successfully");
|
||||
} catch {
|
||||
toast.error("Error deleting domain");
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidateDomain = async (host: string) => {
|
||||
setValidationStates((prev) => ({
|
||||
...prev,
|
||||
@@ -193,37 +140,6 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const columns = createColumns({
|
||||
id,
|
||||
type,
|
||||
validationStates,
|
||||
handleValidateDomain,
|
||||
handleDeleteDomain,
|
||||
isDeleting: isRemoving,
|
||||
serverIp: application?.server?.ipAddress?.toString() || ip?.toString(),
|
||||
canCreateDomain,
|
||||
canDeleteDomain,
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
data: data ?? [],
|
||||
columns,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
@@ -235,32 +151,13 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2 flex-wrap">
|
||||
{data && data?.length > 0 && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const next = viewMode === "grid" ? "table" : "grid";
|
||||
localStorage.setItem("domains-view-mode", next);
|
||||
setViewMode(next);
|
||||
}}
|
||||
>
|
||||
{viewMode === "grid" ? (
|
||||
<LayoutList className="size-4" />
|
||||
) : (
|
||||
<LayoutGrid className="size-4" />
|
||||
)}
|
||||
<div className="flex flex-row gap-4 flex-wrap">
|
||||
{canCreateDomain && data && data?.length > 0 && (
|
||||
<AddDomain id={id} type={type}>
|
||||
<Button>
|
||||
<GlobeIcon className="size-4" /> Add Domain
|
||||
</Button>
|
||||
{canCreateDomain && (
|
||||
<AddDomain id={id} type={type}>
|
||||
<Button>
|
||||
<GlobeIcon className="size-4" /> Add Domain
|
||||
</Button>
|
||||
</AddDomain>
|
||||
)}
|
||||
</>
|
||||
</AddDomain>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -289,122 +186,6 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : viewMode === "table" ? (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex items-center gap-2 max-sm:flex-wrap">
|
||||
<Input
|
||||
placeholder="Filter by host..."
|
||||
value={
|
||||
(table.getColumn("host")?.getFilterValue() as string) ?? ""
|
||||
}
|
||||
onChange={(event) =>
|
||||
table.getColumn("host")?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="md:max-w-sm"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="sm:ml-auto max-sm:w-full"
|
||||
>
|
||||
Columns <ChevronDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(!!value)
|
||||
}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table?.getRowModel()?.rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{data && data?.length > 0 && (
|
||||
<div className="flex items-center justify-end space-x-2 py-4">
|
||||
<div className="space-x-2 flex flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
|
||||
{data?.map((item) => {
|
||||
@@ -560,22 +341,6 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{item.middlewares?.map((middleware, index) => (
|
||||
<TooltipProvider key={`${middleware}-${index}`}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="secondary">
|
||||
<InfoIcon className="size-3 mr-1" />
|
||||
Middleware: {middleware}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Traefik middleware reference</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
))}
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -56,17 +56,17 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
||||
|
||||
const mutationMap = {
|
||||
compose: () => api.compose.saveEnvironment.useMutation(),
|
||||
libsql: () => api.libsql.saveEnvironment.useMutation(),
|
||||
mariadb: () => api.mariadb.saveEnvironment.useMutation(),
|
||||
mongo: () => api.mongo.saveEnvironment.useMutation(),
|
||||
mysql: () => api.mysql.saveEnvironment.useMutation(),
|
||||
postgres: () => api.postgres.saveEnvironment.useMutation(),
|
||||
redis: () => api.redis.saveEnvironment.useMutation(),
|
||||
compose: () => api.compose.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
};
|
||||
const { mutateAsync, isPending } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.mongo.saveEnvironment.useMutation();
|
||||
: api.mongo.update.useMutation();
|
||||
|
||||
const form = useForm<EnvironmentSchema>({
|
||||
defaultValues: {
|
||||
@@ -116,7 +116,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
||||
e.preventDefault();
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
||||
e.preventDefault();
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ interface Props {
|
||||
|
||||
export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||
const { data: sshKeys } = api.sshKey.allForApps.useQuery();
|
||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync, isPending } =
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { GlobeIcon, Pencil, Search, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dropzone } from "@/components/ui/dropzone";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface ShowIconSettingsProps {
|
||||
applicationId: string;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
const svgToDataUrl = (icon: BundledIcon): string => {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#${icon.hex}"><path d="${icon.path}"/></svg>`;
|
||||
return `data:image/svg+xml;base64,${btoa(svg)}`;
|
||||
};
|
||||
|
||||
export const ShowIconSettings = ({
|
||||
applicationId,
|
||||
icon,
|
||||
}: ShowIconSettingsProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [iconSearchQuery, setIconSearchQuery] = useState("");
|
||||
const [iconsToShow, setIconsToShow] = useState(24);
|
||||
|
||||
const filteredIcons = useMemo(() => {
|
||||
if (!iconSearchQuery) return bundledIcons;
|
||||
const q = iconSearchQuery.toLowerCase();
|
||||
return bundledIcons.filter(
|
||||
(i) =>
|
||||
i.title.toLowerCase().includes(q) || i.slug.toLowerCase().includes(q),
|
||||
);
|
||||
}, [iconSearchQuery]);
|
||||
|
||||
const displayedIcons = filteredIcons.slice(0, iconsToShow);
|
||||
const hasMoreIcons = filteredIcons.length > iconsToShow;
|
||||
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync: updateApplication } =
|
||||
api.application.update.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setIconSearchQuery("");
|
||||
setIconsToShow(24);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleIconSelect = async (selectedIcon: BundledIcon) => {
|
||||
try {
|
||||
const dataUrl = svgToDataUrl(selectedIcon);
|
||||
await updateApplication({
|
||||
applicationId,
|
||||
icon: dataUrl,
|
||||
});
|
||||
toast.success("Icon saved successfully");
|
||||
await utils.application.one.invalidate({ applicationId });
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error("Error saving icon");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveIcon = async () => {
|
||||
try {
|
||||
await updateApplication({
|
||||
applicationId,
|
||||
icon: null,
|
||||
});
|
||||
toast.success("Icon removed");
|
||||
await utils.application.one.invalidate({ applicationId });
|
||||
} catch (_error) {
|
||||
toast.error("Error removing icon");
|
||||
}
|
||||
};
|
||||
|
||||
const sanitizeSvg = (svgContent: string): string | null => {
|
||||
const clean = DOMPurify.sanitize(svgContent, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
ADD_TAGS: ["use"],
|
||||
});
|
||||
if (!clean) return null;
|
||||
return `data:image/svg+xml;base64,${btoa(clean)}`;
|
||||
};
|
||||
|
||||
const handleFileUpload = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
const file = files[0];
|
||||
if (!file) return;
|
||||
|
||||
const allowedTypes = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/svg+xml",
|
||||
];
|
||||
const fileExtension = file.name.split(".").pop()?.toLowerCase();
|
||||
const allowedExtensions = ["jpg", "jpeg", "png", "svg"];
|
||||
|
||||
if (
|
||||
!allowedTypes.includes(file.type) &&
|
||||
!allowedExtensions.includes(fileExtension || "")
|
||||
) {
|
||||
toast.error("Only JPG, JPEG, PNG, and SVG files are allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error("Image size must be less than 2MB");
|
||||
return;
|
||||
}
|
||||
|
||||
const isSvg = file.type === "image/svg+xml" || fileExtension === "svg";
|
||||
|
||||
if (isSvg) {
|
||||
const text = await file.text();
|
||||
const sanitizedDataUrl = sanitizeSvg(text);
|
||||
if (!sanitizedDataUrl) {
|
||||
toast.error("Invalid SVG file");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateApplication({
|
||||
applicationId,
|
||||
icon: sanitizedDataUrl,
|
||||
});
|
||||
toast.success("Icon saved!");
|
||||
await utils.application.one.invalidate({ applicationId });
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error("Error saving icon");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
const result = event.target?.result as string;
|
||||
try {
|
||||
await updateApplication({
|
||||
applicationId,
|
||||
icon: result,
|
||||
});
|
||||
toast.success("Icon saved!");
|
||||
await utils.application.one.invalidate({ applicationId });
|
||||
setOpen(false);
|
||||
} catch (_error) {
|
||||
toast.error("Error saving icon");
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="relative group flex items-center justify-center"
|
||||
>
|
||||
{icon ? (
|
||||
// biome-ignore lint/performance/noImgElement: icon is data URL or base64
|
||||
<img
|
||||
src={icon}
|
||||
alt="Application icon"
|
||||
className="h-8 w-8 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<GlobeIcon className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Pencil className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between">
|
||||
Change Icon
|
||||
{icon && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRemoveIcon}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X className="size-4 mr-1" />
|
||||
Remove icon
|
||||
</Button>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search icons (e.g. react, vue, docker)..."
|
||||
value={iconSearchQuery}
|
||||
onChange={(e) => setIconSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[300px] overflow-y-auto border rounded-lg p-4">
|
||||
{displayedIcons.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
No icons found
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
|
||||
{displayedIcons.map((i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={i.slug}
|
||||
onClick={() => handleIconSelect(i)}
|
||||
className="flex flex-col items-center gap-1.5 p-2 rounded-lg border hover:border-primary hover:bg-muted transition-colors group"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
className="size-7 group-hover:scale-110 transition-transform"
|
||||
fill={`#${i.hex}`}
|
||||
>
|
||||
<path d={i.path} />
|
||||
</svg>
|
||||
<span className="text-[10px] text-muted-foreground capitalize truncate w-full text-center">
|
||||
{i.title}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{hasMoreIcons && (
|
||||
<div className="flex justify-center mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIconsToShow((prev) => prev + 24)}
|
||||
>
|
||||
Load More ({filteredIcons.length - iconsToShow} remaining)
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative pt-3 border-t">
|
||||
<p className="text-sm text-muted-foreground text-center mb-3">
|
||||
or upload a custom icon
|
||||
</p>
|
||||
<Dropzone
|
||||
dropMessage="Drag & drop an icon or click to upload"
|
||||
accept=".jpg,.jpeg,.png,.svg,image/jpeg,image/png,image/svg+xml"
|
||||
onChange={handleFileUpload}
|
||||
classNameWrapper="border-2 border-dashed border-border hover:border-primary bg-muted/30 hover:bg-muted/50 transition-all rounded-lg"
|
||||
/>
|
||||
<div className="mt-2 text-center text-xs text-muted-foreground">
|
||||
Supported formats: JPG, JPEG, PNG, SVG (max 2MB)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -91,7 +91,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
}, [option, services, containers]);
|
||||
|
||||
const isLoading = option === "native" ? containersLoading : servicesLoading;
|
||||
const containersLength =
|
||||
const containersLenght =
|
||||
option === "native" ? containers?.length : services?.length;
|
||||
|
||||
return (
|
||||
@@ -167,7 +167,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
<SelectLabel>Containers ({containersLength})</SelectLabel>
|
||||
<SelectLabel>Containers ({containersLenght})</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./patch-editor";
|
||||
export * from "./show-patches";
|
||||
export * from "./patch-editor";
|
||||
|
||||
@@ -483,7 +483,7 @@ export const HandleVolumeBackups = ({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose the volume to backup. If you do not see the
|
||||
Choose the volume to backup, if you dont see the
|
||||
volume here, you can type the volume name manually
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
@@ -518,7 +518,7 @@ export const HandleVolumeBackups = ({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose the volume to backup. If you do not see the volume
|
||||
Choose the volume to backup, if you dont see the volume
|
||||
here, you can type the volume name manually
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
|
||||
@@ -95,7 +95,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
||||
e.preventDefault();
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ interface Props {
|
||||
|
||||
export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
const { data, refetch } = api.compose.one.useQuery({ composeId });
|
||||
const { data: sshKeys } = api.sshKey.allForApps.useQuery();
|
||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync, isPending } = api.compose.update.useMutation();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, Plus, X, HelpCircle } from "lucide-react";
|
||||
import { CheckIcon, ChevronsUpDown, Plus, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -409,8 +409,10 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
<TooltipTrigger>
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
|
||||
@@ -77,7 +77,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||
}, [option, services, containers]);
|
||||
|
||||
const isLoading = option === "native" ? containersLoading : servicesLoading;
|
||||
const containersLength =
|
||||
const containersLenght =
|
||||
option === "native" ? containers?.length : services?.length;
|
||||
|
||||
return (
|
||||
@@ -152,7 +152,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
<SelectLabel>Containers ({containersLength})</SelectLabel>
|
||||
<SelectLabel>Containers ({containersLenght})</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -225,7 +225,7 @@ export const RestoreBackup = ({
|
||||
resolver: zodResolver(RestoreBackupSchema),
|
||||
});
|
||||
|
||||
const destinationId = form.watch("destinationId");
|
||||
const destionationId = form.watch("destinationId");
|
||||
const currentDatabaseType = form.watch("databaseType");
|
||||
const metadata = form.watch("metadata");
|
||||
|
||||
@@ -240,12 +240,12 @@ export const RestoreBackup = ({
|
||||
|
||||
const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery(
|
||||
{
|
||||
destinationId: destinationId,
|
||||
destinationId: destionationId,
|
||||
search: debouncedSearchTerm,
|
||||
serverId: serverId ?? "",
|
||||
},
|
||||
{
|
||||
enabled: isOpen && !!destinationId,
|
||||
enabled: isOpen && !!destionationId,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
import { ArrowRight, ListTodo, Loader2, XCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, ListTodo, Loader2, XCircle } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
"use client";
|
||||
import { Bot, Loader2, RotateCcw, Settings, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
import type { LogLine } from "./utils";
|
||||
|
||||
interface Props {
|
||||
logs: LogLine[];
|
||||
context: "build" | "runtime";
|
||||
}
|
||||
|
||||
const MAX_LOG_LINES = 200;
|
||||
|
||||
export function AnalyzeLogs({ logs, context }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [aiId, setAiId] = useState<string>("");
|
||||
const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, {
|
||||
enabled: open,
|
||||
});
|
||||
const { mutate, isPending, data, reset } = api.ai.analyzeLogs.useMutation({
|
||||
onError: (error) => {
|
||||
toast.error("Analysis failed", {
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleAnalyze = () => {
|
||||
if (!aiId || logs.length === 0) return;
|
||||
|
||||
const logsText = logs
|
||||
.slice(-MAX_LOG_LINES)
|
||||
.map((l) => l.message)
|
||||
.join("\n");
|
||||
|
||||
mutate({ aiId, logs: logsText, context });
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
reset();
|
||||
setAiId("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9"
|
||||
disabled={logs.length === 0}
|
||||
title="Analyze logs with AI"
|
||||
>
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
AI
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[550px] p-0" align="end">
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Log Analysis</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
{!data?.analysis ? (
|
||||
providers && providers.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-2 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No AI providers configured. Set up a provider to start
|
||||
analyzing logs.
|
||||
</p>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link href="/dashboard/settings/ai">
|
||||
<Settings className="mr-2 h-3.5 w-3.5" />
|
||||
Configure AI Provider
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select value={aiId} onValueChange={setAiId}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select AI provider..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers?.map((p) => (
|
||||
<SelectItem key={p.aiId} value={p.aiId}>
|
||||
{p.name} ({p.model})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={!aiId || isPending || logs.length === 0}
|
||||
onClick={handleAnalyze}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Analyzing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bot className="mr-2 h-3.5 w-3.5" />
|
||||
Analyze{" "}
|
||||
{logs.length > MAX_LOG_LINES
|
||||
? `last ${MAX_LOG_LINES}`
|
||||
: logs.length}{" "}
|
||||
lines
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-sm break-words">
|
||||
<ReactMarkdown>{data.analysis}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
reset();
|
||||
handleAnalyze();
|
||||
}}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
||||
)}
|
||||
Re-analyze
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
reset();
|
||||
setAiId("");
|
||||
}}
|
||||
title="Change provider"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { AnalyzeLogs } from "./analyze-logs";
|
||||
import { LineCountFilter } from "./line-count-filter";
|
||||
import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter";
|
||||
import { StatusLogsFilter } from "./status-logs-filter";
|
||||
@@ -378,7 +377,6 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||
Download logs
|
||||
</Button>
|
||||
<AnalyzeLogs logs={filteredLogs} context="runtime" />
|
||||
</div>
|
||||
</div>
|
||||
{isPaused && (
|
||||
|
||||
@@ -103,7 +103,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
|
||||
>
|
||||
{" "}
|
||||
<div className="flex items-start gap-x-2">
|
||||
{/* Icon to expand the log item maybe implement a collapsible later */}
|
||||
{/* Icon to expand the log item maybe implement a colapsible later */}
|
||||
{/* <Square className="size-4 text-muted-foreground opacity-0 group-hover/logitem:opacity-100 transition-opacity" /> */}
|
||||
{tooltip(color, rawTimestamp)}
|
||||
{!noTimestamp && (
|
||||
|
||||
@@ -74,18 +74,6 @@ export function parseLogs(logString: string): LogLine[] {
|
||||
|
||||
// Detect log type based on message content
|
||||
export const getLogType = (message: string): LogStyle => {
|
||||
// Detect HTTP statusCode
|
||||
const statusMatch = message.match(/"statusCode"\s*:\s*"?(\d{3})"?/);
|
||||
|
||||
if (statusMatch) {
|
||||
const statusCode = Number(statusMatch[1]);
|
||||
|
||||
if (statusCode >= 500) return LOG_STYLES.error;
|
||||
if (statusCode >= 400) return LOG_STYLES.warning;
|
||||
if (statusCode >= 200 && statusCode < 300) return LOG_STYLES.success;
|
||||
return LOG_STYLES.info;
|
||||
}
|
||||
|
||||
const lowerMessage = message.toLowerCase();
|
||||
|
||||
if (
|
||||
|
||||
@@ -12,7 +12,6 @@ import { ShowContainerConfig } from "../config/show-container-config";
|
||||
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
|
||||
import { RemoveContainerDialog } from "../remove/remove-container";
|
||||
import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
|
||||
import { UploadFileModal } from "../upload/upload-file-modal";
|
||||
import type { Container } from "./show-containers";
|
||||
|
||||
export const columns: ColumnDef<Container>[] = [
|
||||
@@ -129,12 +128,6 @@ export const columns: ColumnDef<Container>[] = [
|
||||
>
|
||||
Terminal
|
||||
</DockerTerminalModal>
|
||||
<UploadFileModal
|
||||
containerId={container.containerId}
|
||||
serverId={container.serverId || undefined}
|
||||
>
|
||||
Upload File
|
||||
</UploadFileModal>
|
||||
<RemoveContainerDialog
|
||||
containerId={container.containerId}
|
||||
serverId={container.serverId ?? undefined}
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { api, type RouterOutputs } from "@/utils/api";
|
||||
import { columns } from "./columns";
|
||||
import { columns } from "./colums";
|
||||
export type Container = NonNullable<
|
||||
RouterOutputs["docker"]["getContainers"]
|
||||
>[0];
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Upload } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { Dropzone } from "@/components/ui/dropzone";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
uploadFileToContainerSchema,
|
||||
type UploadFileToContainer,
|
||||
} from "@/utils/schema";
|
||||
|
||||
interface Props {
|
||||
containerId: string;
|
||||
serverId?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const UploadFileModal = ({ children, containerId, serverId }: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { mutateAsync: uploadFile, isPending: isLoading } =
|
||||
api.docker.uploadFileToContainer.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("File uploaded successfully");
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to upload file to container");
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(uploadFileToContainerSchema),
|
||||
defaultValues: {
|
||||
containerId,
|
||||
destinationPath: "/",
|
||||
serverId: serverId || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const file = form.watch("file");
|
||||
|
||||
const onSubmit = async (values: UploadFileToContainer) => {
|
||||
if (!values.file) {
|
||||
toast.error("Please select a file to upload");
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("containerId", values.containerId);
|
||||
formData.append("file", values.file);
|
||||
formData.append("destinationPath", values.destinationPath);
|
||||
if (values.serverId) {
|
||||
formData.append("serverId", values.serverId);
|
||||
}
|
||||
|
||||
await uploadFile(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{children}
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5" />
|
||||
Upload File to Container
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload a file directly into the container's filesystem
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="destinationPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Destination Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="/path/to/file"
|
||||
className="font-mono"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter the full path where the file should be uploaded in the
|
||||
container (e.g., /app/config.json)
|
||||
</p>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="file"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>File</FormLabel>
|
||||
<FormControl>
|
||||
<Dropzone
|
||||
{...field}
|
||||
dropMessage="Drop file here or click to browse"
|
||||
onChange={(files) => {
|
||||
if (files && files.length > 0) {
|
||||
field.onChange(files[0]);
|
||||
} else {
|
||||
field.onChange(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
{file instanceof File && (
|
||||
<div className="flex items-center gap-2 p-2 bg-muted rounded-md">
|
||||
<span className="text-sm text-muted-foreground flex-1">
|
||||
{file.name} ({(file.size / 1024).toFixed(2)} KB)
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => field.onChange(null)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isLoading}
|
||||
disabled={!file || isLoading}
|
||||
>
|
||||
Upload File
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +1,14 @@
|
||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
mariadbId: string;
|
||||
}
|
||||
export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
|
||||
const { data } = api.mariadb.one.useQuery({ mariadbId });
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync: changePassword } =
|
||||
api.mariadb.changePassword.useMutation();
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
@@ -33,43 +28,20 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Password</Label>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<div className="flex flex-row gap-4">
|
||||
<ToggleVisibilityInput
|
||||
disabled
|
||||
value={data?.databasePassword}
|
||||
/>
|
||||
<UpdateDatabasePassword
|
||||
onUpdatePassword={async (newPassword) => {
|
||||
await changePassword({
|
||||
mariadbId,
|
||||
password: newPassword,
|
||||
type: "user",
|
||||
});
|
||||
toast.success("Password updated successfully");
|
||||
utils.mariadb.one.invalidate({ mariadbId });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Root Password</Label>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<div className="flex flex-row gap-4">
|
||||
<ToggleVisibilityInput
|
||||
disabled
|
||||
value={data?.databaseRootPassword}
|
||||
/>
|
||||
<UpdateDatabasePassword
|
||||
label="Root Password"
|
||||
onUpdatePassword={async (newPassword) => {
|
||||
await changePassword({
|
||||
mariadbId,
|
||||
password: newPassword,
|
||||
type: "root",
|
||||
});
|
||||
toast.success("Root password updated successfully");
|
||||
utils.mariadb.one.invalidate({ mariadbId });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -82,8 +82,7 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
const buildConnectionUrl = () => {
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
|
||||
const params = `authSource=admin${data?.replicaSets ? "" : "&directConnection=true"}`;
|
||||
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/?${params}`;
|
||||
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`;
|
||||
};
|
||||
|
||||
setConnectionUrl(buildConnectionUrl());
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
mongoId: string;
|
||||
}
|
||||
export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
const { data } = api.mongo.one.useQuery({ mongoId });
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync: changePassword } =
|
||||
api.mongo.changePassword.useMutation();
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
@@ -30,21 +25,11 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Password</Label>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<div className="flex flex-row gap-4">
|
||||
<ToggleVisibilityInput
|
||||
disabled
|
||||
value={data?.databasePassword}
|
||||
/>
|
||||
<UpdateDatabasePassword
|
||||
onUpdatePassword={async (newPassword) => {
|
||||
await changePassword({
|
||||
mongoId,
|
||||
password: newPassword,
|
||||
});
|
||||
toast.success("Password updated successfully");
|
||||
utils.mongo.one.invalidate({ mongoId });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,7 +47,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
<Label>Internal Connection URL </Label>
|
||||
<ToggleVisibilityInput
|
||||
disabled
|
||||
value={`mongodb://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:27017/?authSource=admin${data?.replicaSets ? "" : "&directConnection=true"}`}
|
||||
value={`mongodb://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:27017`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,103 +1,103 @@
|
||||
import { format } from "date-fns";
|
||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||
|
||||
interface Props {
|
||||
accumulativeData: DockerStatsJSON["block"];
|
||||
acummulativeData: DockerStatsJSON["block"];
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
readMb: {
|
||||
label: "Read (MB)",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
writeMb: {
|
||||
label: "Write (MB)",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export const DockerBlockChart = ({ accumulativeData }: Props) => {
|
||||
const transformedData = accumulativeData.map((item, index) => ({
|
||||
time: item.time,
|
||||
name: `Point ${index + 1}`,
|
||||
readMb: item.value.readMb,
|
||||
writeMb: item.value.writeMb,
|
||||
}));
|
||||
export const DockerBlockChart = ({ acummulativeData }: Props) => {
|
||||
const transformedData = acummulativeData.map((item, index) => {
|
||||
return {
|
||||
time: item.time,
|
||||
name: `Point ${index + 1}`,
|
||||
readMb: item.value.readMb,
|
||||
writeMb: item.value.writeMb,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
|
||||
<AreaChart
|
||||
data={transformedData}
|
||||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="fillBlockRead" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-readMb)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-readMb)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="fillBlockWrite" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-writeMb)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-writeMb)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis tickLine={false} axisLine={false} />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
const time = payload?.[0]?.payload?.time;
|
||||
return time ? format(new Date(time), "PPpp") : "";
|
||||
}}
|
||||
formatter={(value, name) => {
|
||||
const label = name === "readMb" ? "Read" : "Write";
|
||||
return [`${value} MB`, label];
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="readMb"
|
||||
stroke="var(--color-readMb)"
|
||||
fill="url(#fillBlockRead)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="writeMb"
|
||||
stroke="var(--color-writeMb)"
|
||||
fill="url(#fillBlockWrite)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
<div className="mt-6 w-full h-[10rem]">
|
||||
<ResponsiveContainer>
|
||||
<AreaChart
|
||||
data={transformedData}
|
||||
margin={{
|
||||
top: 10,
|
||||
right: 30,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#8884d8" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorWrite" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#82ca9d" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#82ca9d" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<YAxis stroke="#A1A1AA" />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
|
||||
{/* @ts-ignore */}
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="readMb"
|
||||
stroke="#27272A"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorUv)"
|
||||
name="Read Mb"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="writeMb"
|
||||
stroke="#82ca9d"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorWrite)"
|
||||
name="Write Mb"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
interface CustomTooltipProps {
|
||||
active: boolean;
|
||||
payload?: {
|
||||
color?: string;
|
||||
dataKey?: string;
|
||||
value?: number;
|
||||
payload: {
|
||||
time: string;
|
||||
readMb: number;
|
||||
writeMb: number;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
|
||||
if (active && payload && payload.length && payload[0]) {
|
||||
return (
|
||||
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
|
||||
{payload[0].payload.time && (
|
||||
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
|
||||
)}
|
||||
<p>{`Read ${payload[0].payload.readMb} `}</p>
|
||||
<p>{`Write: ${payload[0].payload.writeMb} `}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,81 +1,87 @@
|
||||
import { format } from "date-fns";
|
||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||
|
||||
interface Props {
|
||||
accumulativeData: DockerStatsJSON["cpu"];
|
||||
acummulativeData: DockerStatsJSON["cpu"];
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
usage: {
|
||||
label: "CPU Usage",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export const DockerCpuChart = ({ accumulativeData }: Props) => {
|
||||
const transformedData = accumulativeData.map((item, index) => ({
|
||||
name: `Point ${index + 1}`,
|
||||
time: item.time,
|
||||
usage: item.value.toString().split("%")[0],
|
||||
}));
|
||||
|
||||
export const DockerCpuChart = ({ acummulativeData }: Props) => {
|
||||
const transformedData = acummulativeData.map((item, index) => {
|
||||
return {
|
||||
name: `Point ${index + 1}`,
|
||||
time: item.time,
|
||||
usage: item.value.toString().split("%")[0],
|
||||
};
|
||||
});
|
||||
return (
|
||||
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
|
||||
<AreaChart
|
||||
data={transformedData}
|
||||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="fillCpu" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-usage)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-usage)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
domain={[0, 100]}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
const time = payload?.[0]?.payload?.time;
|
||||
return time ? format(new Date(time), "PPpp") : "";
|
||||
}}
|
||||
formatter={(value) => [`${value}%`, "CPU Usage"]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="usage"
|
||||
stroke="var(--color-usage)"
|
||||
fill="url(#fillCpu)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
<div className="mt-6 w-full h-[10rem]">
|
||||
<ResponsiveContainer>
|
||||
<AreaChart
|
||||
data={transformedData}
|
||||
margin={{
|
||||
top: 10,
|
||||
right: 30,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="white" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<YAxis stroke="#A1A1AA" domain={[0, 100]} />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
|
||||
{/* @ts-ignore */}
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="usage"
|
||||
stroke="#27272A"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorUv)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CustomTooltipProps {
|
||||
active: boolean;
|
||||
payload?: {
|
||||
color?: string;
|
||||
dataKey?: string;
|
||||
value?: number;
|
||||
payload: {
|
||||
time: string;
|
||||
usage: number;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
|
||||
if (active && payload && payload.length && payload[0]) {
|
||||
return (
|
||||
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
|
||||
{payload[0].payload.time && (
|
||||
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
|
||||
)}
|
||||
<p>{`CPU Usage: ${payload[0].payload.usage}%`}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,82 +1,105 @@
|
||||
import { format } from "date-fns";
|
||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||
|
||||
interface Props {
|
||||
accumulativeData: DockerStatsJSON["disk"];
|
||||
acummulativeData: DockerStatsJSON["disk"];
|
||||
diskTotal: number;
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
usedGb: {
|
||||
label: "Used (GB)",
|
||||
color: "hsl(var(--chart-3))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export const DockerDiskChart = ({ accumulativeData, diskTotal }: Props) => {
|
||||
const transformedData = accumulativeData.map((item, index) => ({
|
||||
time: item.time,
|
||||
name: `Point ${index + 1}`,
|
||||
usedGb: +item.value.diskUsage,
|
||||
totalGb: +item.value.diskTotal,
|
||||
}));
|
||||
export const DockerDiskChart = ({ acummulativeData, diskTotal }: Props) => {
|
||||
const transformedData = acummulativeData.map((item, index) => {
|
||||
return {
|
||||
time: item.time,
|
||||
name: `Point ${index + 1}`,
|
||||
usedGb: +item.value.diskUsage,
|
||||
totalGb: +item.value.diskTotal,
|
||||
freeGb: item.value.diskFree,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
|
||||
<AreaChart
|
||||
data={transformedData}
|
||||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="fillDiskUsed" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-usedGb)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-usedGb)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis
|
||||
domain={[0, diskTotal]}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value} GB`}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
const time = payload?.[0]?.payload?.time;
|
||||
return time ? format(new Date(time), "PPpp") : "";
|
||||
}}
|
||||
formatter={(value) => {
|
||||
return [`${value} GB`, "Used"];
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="usedGb"
|
||||
stroke="var(--color-usedGb)"
|
||||
fill="url(#fillDiskUsed)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
<div className="mt-6 w-full h-[10rem]">
|
||||
<ResponsiveContainer>
|
||||
<AreaChart
|
||||
data={transformedData}
|
||||
margin={{
|
||||
top: 10,
|
||||
right: 30,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorUsed" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#6C28D9" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#6C28D9" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorFree" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#6C28D9" stopOpacity={0.2} />
|
||||
<stop offset="95%" stopColor="#6C28D9" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<YAxis stroke="#A1A1AA" domain={[0, diskTotal]} />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
|
||||
{/* @ts-ignore */}
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="usedGb"
|
||||
stroke="#6C28D9"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorUsed)"
|
||||
name="Used GB"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="freeGb"
|
||||
stroke="#8884d8"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorFree)"
|
||||
name="Free GB"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
interface CustomTooltipProps {
|
||||
active: boolean;
|
||||
payload?: {
|
||||
color?: string;
|
||||
dataKey?: string;
|
||||
value?: number;
|
||||
payload: {
|
||||
time: string;
|
||||
usedGb: number;
|
||||
freeGb: number;
|
||||
totalGb: number;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
|
||||
if (active && payload && payload.length && payload[0]) {
|
||||
return (
|
||||
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
|
||||
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
|
||||
<p>{`Disk usage: ${payload[0].payload.usedGb} GB`}</p>
|
||||
<p>{`Disk free: ${payload[0].payload.freeGb} GB`}</p>
|
||||
<p>{`Total disk: ${payload[0].payload.totalGb} GB`}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { Cell, Label, Pie, PieChart } from "recharts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const TYPE_TO_KEY: Record<string, string> = {
|
||||
Images: "images",
|
||||
Containers: "containers",
|
||||
"Local Volumes": "volumes",
|
||||
"Build Cache": "buildCache",
|
||||
};
|
||||
|
||||
const chartConfig = {
|
||||
value: {
|
||||
label: "Size",
|
||||
},
|
||||
images: {
|
||||
label: "Images",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
containers: {
|
||||
label: "Containers",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
volumes: {
|
||||
label: "Volumes",
|
||||
color: "hsl(var(--chart-3))",
|
||||
},
|
||||
buildCache: {
|
||||
label: "Build Cache",
|
||||
color: "hsl(var(--chart-4))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const formatSize = (bytes: number): string => {
|
||||
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
||||
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
};
|
||||
|
||||
export const DockerDiskUsageChart = () => {
|
||||
const { data, isLoading, refetch, isRefetching } =
|
||||
api.settings.getDockerDiskUsage.useQuery(undefined, {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const { chartData, totalBytes } = useMemo(() => {
|
||||
const items =
|
||||
data
|
||||
?.filter((item) => item.sizeBytes > 0)
|
||||
.map((item) => {
|
||||
const key = TYPE_TO_KEY[item.type] ?? item.type;
|
||||
return {
|
||||
name: key,
|
||||
value: item.sizeBytes,
|
||||
size: item.size,
|
||||
active: item.active,
|
||||
totalCount: item.totalCount,
|
||||
reclaimable: item.reclaimable,
|
||||
fill: `var(--color-${key})`,
|
||||
};
|
||||
}) ?? [];
|
||||
return {
|
||||
chartData: items,
|
||||
totalBytes: items.reduce((sum, item) => sum + item.value, 0),
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[16rem]">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground mt-4">
|
||||
No Docker disk usage data available.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Total: {formatSize(totalBytes)}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => refetch()}
|
||||
disabled={isRefetching}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3.5 ${isRefetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="mx-auto w-full max-h-[250px] [&_.recharts-pie-label-text]:fill-foreground"
|
||||
>
|
||||
<PieChart>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
nameKey="name"
|
||||
formatter={(value, name) => {
|
||||
const item = chartData.find((d) => d.name === name);
|
||||
if (!item) return [formatSize(value as number), name];
|
||||
return [
|
||||
`${item.size} — ${item.active} active / ${item.totalCount} total — Reclaimable: ${item.reclaimable}`,
|
||||
chartConfig[name as keyof typeof chartConfig]?.label ??
|
||||
name,
|
||||
];
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius={60}
|
||||
outerRadius={85}
|
||||
strokeWidth={3}
|
||||
stroke="hsl(var(--background))"
|
||||
minAngle={15}
|
||||
>
|
||||
{chartData.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.fill} />
|
||||
))}
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||
return (
|
||||
<text
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={(viewBox.cy || 0) - 8}
|
||||
className="fill-foreground text-2xl font-bold"
|
||||
>
|
||||
{formatSize(totalBytes)}
|
||||
</tspan>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={(viewBox.cy || 0) + 14}
|
||||
className="fill-muted-foreground text-xs"
|
||||
>
|
||||
Docker Usage
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
<ChartLegend content={<ChartLegendContent nameKey="name" />} />
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,87 +1,93 @@
|
||||
import { format } from "date-fns";
|
||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||
import { convertMemoryToBytes } from "./show-free-container-monitoring";
|
||||
|
||||
interface Props {
|
||||
accumulativeData: DockerStatsJSON["memory"];
|
||||
acummulativeData: DockerStatsJSON["memory"];
|
||||
memoryLimitGB: number;
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
usage: {
|
||||
label: "Memory (GB)",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export const DockerMemoryChart = ({
|
||||
accumulativeData,
|
||||
acummulativeData,
|
||||
memoryLimitGB,
|
||||
}: Props) => {
|
||||
const transformedData = accumulativeData.map((item, index) => ({
|
||||
time: item.time,
|
||||
name: `Point ${index + 1}`,
|
||||
// @ts-ignore
|
||||
usage: (convertMemoryToBytes(item.value.used) / 1024 ** 3).toFixed(2),
|
||||
}));
|
||||
|
||||
const transformedData = acummulativeData.map((item, index) => {
|
||||
return {
|
||||
time: item.time,
|
||||
name: `Point ${index + 1}`,
|
||||
// @ts-ignore
|
||||
usage: (convertMemoryToBytes(item.value.used) / 1024 ** 3).toFixed(2),
|
||||
};
|
||||
});
|
||||
return (
|
||||
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
|
||||
<AreaChart
|
||||
data={transformedData}
|
||||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="fillMemory" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-usage)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-usage)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis
|
||||
tickFormatter={(value) => `${value} GB`}
|
||||
domain={[0, +memoryLimitGB.toFixed(2)]}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
const time = payload?.[0]?.payload?.time;
|
||||
return time ? format(new Date(time), "PPpp") : "";
|
||||
}}
|
||||
formatter={(value) => [`${value} GB`, "Memory"]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="usage"
|
||||
stroke="var(--color-usage)"
|
||||
fill="url(#fillMemory)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
<div className="mt-6 w-full h-[10rem]">
|
||||
<ResponsiveContainer>
|
||||
<AreaChart
|
||||
data={transformedData}
|
||||
margin={{
|
||||
top: 10,
|
||||
right: 30,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="white" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<YAxis stroke="#A1A1AA" domain={[0, +memoryLimitGB.toFixed(2)]} />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
|
||||
{/* @ts-ignore */}
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="usage"
|
||||
stroke="#27272A"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorUv)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
interface CustomTooltipProps {
|
||||
active: boolean;
|
||||
payload?: {
|
||||
color?: string;
|
||||
dataKey?: string;
|
||||
value?: number;
|
||||
payload: {
|
||||
time: string;
|
||||
usage: number;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
|
||||
if (active && payload && payload.length && payload[0] && payload[0].payload) {
|
||||
return (
|
||||
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
|
||||
{payload[0].payload.time && (
|
||||
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
|
||||
)}
|
||||
|
||||
<p>{`Memory usage: ${payload[0].payload.usage} GB`}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,99 +1,99 @@
|
||||
import { format } from "date-fns";
|
||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||
|
||||
interface Props {
|
||||
accumulativeData: DockerStatsJSON["network"];
|
||||
acummulativeData: DockerStatsJSON["network"];
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
inMB: {
|
||||
label: "In (MB)",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
outMB: {
|
||||
label: "Out (MB)",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export const DockerNetworkChart = ({ accumulativeData }: Props) => {
|
||||
const transformedData = accumulativeData.map((item, index) => ({
|
||||
time: item.time,
|
||||
name: `Point ${index + 1}`,
|
||||
inMB: item.value.inputMb,
|
||||
outMB: item.value.outputMb,
|
||||
}));
|
||||
|
||||
export const DockerNetworkChart = ({ acummulativeData }: Props) => {
|
||||
const transformedData = acummulativeData.map((item, index) => {
|
||||
return {
|
||||
time: item.time,
|
||||
name: `Point ${index + 1}`,
|
||||
inMB: item.value.inputMb,
|
||||
outMB: item.value.outputMb,
|
||||
};
|
||||
});
|
||||
return (
|
||||
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
|
||||
<AreaChart
|
||||
data={transformedData}
|
||||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="fillNetIn" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-inMB)" stopOpacity={0.8} />
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-inMB)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="fillNetOut" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-outMB)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-outMB)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis tickLine={false} axisLine={false} />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
const time = payload?.[0]?.payload?.time;
|
||||
return time ? format(new Date(time), "PPpp") : "";
|
||||
}}
|
||||
formatter={(value, name) => {
|
||||
const label = name === "inMB" ? "In" : "Out";
|
||||
return [`${value} MB`, label];
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="inMB"
|
||||
stroke="var(--color-inMB)"
|
||||
fill="url(#fillNetIn)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="outMB"
|
||||
stroke="var(--color-outMB)"
|
||||
fill="url(#fillNetOut)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
<div className="mt-6 w-full h-[10rem]">
|
||||
<ResponsiveContainer>
|
||||
<AreaChart
|
||||
data={transformedData}
|
||||
margin={{
|
||||
top: 10,
|
||||
right: 30,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="white" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<YAxis stroke="#A1A1AA" />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
|
||||
{/* @ts-ignore */}
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="inMB"
|
||||
stroke="#8884d8"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorUv)"
|
||||
name="In MB"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="outMB"
|
||||
stroke="#82ca9d"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorUv)"
|
||||
name="Out MB"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CustomTooltipProps {
|
||||
active: boolean;
|
||||
payload?: {
|
||||
color?: string;
|
||||
dataKey?: string;
|
||||
value?: number;
|
||||
payload: {
|
||||
time: string;
|
||||
inMB: number;
|
||||
outMB: number;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
|
||||
if (active && payload && payload.length && payload[0]) {
|
||||
return (
|
||||
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
|
||||
{payload[0].payload.time && (
|
||||
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
|
||||
)}
|
||||
<p>{`In Usage: ${payload[0].payload.inMB} `}</p>
|
||||
<p>{`Out Usage: ${payload[0].payload.outMB} `}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ import { api } from "@/utils/api";
|
||||
import { DockerBlockChart } from "./docker-block-chart";
|
||||
import { DockerCpuChart } from "./docker-cpu-chart";
|
||||
import { DockerDiskChart } from "./docker-disk-chart";
|
||||
import { DockerDiskUsageChart } from "./docker-disk-usage-chart";
|
||||
import { DockerMemoryChart } from "./docker-memory-chart";
|
||||
import { DockerNetworkChart } from "./docker-network-chart";
|
||||
|
||||
@@ -125,7 +124,7 @@ export const ContainerFreeMonitoring = ({
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
const [accumulativeData, setAccumulativeData] = useState<DockerStatsJSON>({
|
||||
const [acummulativeData, setAcummulativeData] = useState<DockerStatsJSON>({
|
||||
cpu: [],
|
||||
memory: [],
|
||||
block: [],
|
||||
@@ -137,7 +136,7 @@ export const ContainerFreeMonitoring = ({
|
||||
useEffect(() => {
|
||||
setCurrentData(defaultData);
|
||||
|
||||
setAccumulativeData({
|
||||
setAcummulativeData({
|
||||
cpu: [],
|
||||
memory: [],
|
||||
block: [],
|
||||
@@ -156,7 +155,7 @@ export const ContainerFreeMonitoring = ({
|
||||
network: data.network[data.network.length - 1] ?? currentData.network,
|
||||
disk: data.disk[data.disk.length - 1] ?? currentData.disk,
|
||||
});
|
||||
setAccumulativeData({
|
||||
setAcummulativeData({
|
||||
block: data?.block || [],
|
||||
cpu: data?.cpu || [],
|
||||
disk: data?.disk || [],
|
||||
@@ -185,7 +184,7 @@ export const ContainerFreeMonitoring = ({
|
||||
setCurrentData(data);
|
||||
|
||||
const MAX_DATA_POINTS = 300;
|
||||
setAccumulativeData((prevData) => ({
|
||||
setAcummulativeData((prevData) => ({
|
||||
cpu: [...prevData.cpu, data.cpu].slice(-MAX_DATA_POINTS),
|
||||
memory: [...prevData.memory, data.memory].slice(-MAX_DATA_POINTS),
|
||||
block: [...prevData.block, data.block].slice(-MAX_DATA_POINTS),
|
||||
@@ -220,16 +219,16 @@ export const ContainerFreeMonitoring = ({
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Used: {String(currentData.cpu.value ?? "0%")}
|
||||
Used: {currentData.cpu.value}
|
||||
</span>
|
||||
<Progress
|
||||
value={Number.parseInt(
|
||||
String(currentData.cpu.value ?? "0%").replace("%", ""),
|
||||
currentData.cpu.value.replace("%", ""),
|
||||
10,
|
||||
)}
|
||||
className="w-[100%]"
|
||||
/>
|
||||
<DockerCpuChart accumulativeData={accumulativeData.cpu} />
|
||||
<DockerCpuChart acummulativeData={acummulativeData.cpu} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -253,7 +252,7 @@ export const ContainerFreeMonitoring = ({
|
||||
className="w-[100%]"
|
||||
/>
|
||||
<DockerMemoryChart
|
||||
accumulativeData={accumulativeData.memory}
|
||||
acummulativeData={acummulativeData.memory}
|
||||
memoryLimitGB={
|
||||
// @ts-ignore
|
||||
convertMemoryToBytes(currentData.memory.value.total) /
|
||||
@@ -278,25 +277,13 @@ export const ContainerFreeMonitoring = ({
|
||||
className="w-[100%]"
|
||||
/>
|
||||
<DockerDiskChart
|
||||
accumulativeData={accumulativeData.disk}
|
||||
acummulativeData={acummulativeData.disk}
|
||||
diskTotal={currentData.disk.value.diskTotal}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{appName === "dokploy" && (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Docker Disk Usage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DockerDiskUsageChart />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
@@ -307,7 +294,7 @@ export const ContainerFreeMonitoring = ({
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{`Read: ${currentData.block.value.readMb} / Write: ${currentData.block.value.writeMb} `}
|
||||
</span>
|
||||
<DockerBlockChart accumulativeData={accumulativeData.block} />
|
||||
<DockerBlockChart acummulativeData={acummulativeData.block} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -320,7 +307,7 @@ export const ContainerFreeMonitoring = ({
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{`In MB: ${currentData.network.value.inputMb} / Out MB: ${currentData.network.value.outputMb} `}
|
||||
</span>
|
||||
<DockerNetworkChart accumulativeData={accumulativeData.network} />
|
||||
<DockerNetworkChart acummulativeData={acummulativeData.network} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
mysqlId: string;
|
||||
}
|
||||
export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
|
||||
const { data } = api.mysql.one.useQuery({ mysqlId });
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync: changePassword } =
|
||||
api.mysql.changePassword.useMutation();
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
@@ -33,43 +28,20 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Password</Label>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<div className="flex flex-row gap-4">
|
||||
<ToggleVisibilityInput
|
||||
disabled
|
||||
value={data?.databasePassword}
|
||||
/>
|
||||
<UpdateDatabasePassword
|
||||
onUpdatePassword={async (newPassword) => {
|
||||
await changePassword({
|
||||
mysqlId,
|
||||
password: newPassword,
|
||||
type: "user",
|
||||
});
|
||||
toast.success("Password updated successfully");
|
||||
utils.mysql.one.invalidate({ mysqlId });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Root Password</Label>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<div className="flex flex-row gap-4">
|
||||
<ToggleVisibilityInput
|
||||
disabled
|
||||
value={data?.databaseRootPassword}
|
||||
/>
|
||||
<UpdateDatabasePassword
|
||||
label="Root Password"
|
||||
onUpdatePassword={async (newPassword) => {
|
||||
await changePassword({
|
||||
mysqlId,
|
||||
password: newPassword,
|
||||
type: "root",
|
||||
});
|
||||
toast.success("Root password updated successfully");
|
||||
utils.mysql.one.invalidate({ mysqlId });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
postgresId: string;
|
||||
}
|
||||
export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||
const { data } = api.postgres.one.useQuery({ postgresId });
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync: changePassword } =
|
||||
api.postgres.changePassword.useMutation();
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
@@ -33,21 +28,11 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Password</Label>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<div className="flex flex-row gap-4">
|
||||
<ToggleVisibilityInput
|
||||
value={data?.databasePassword}
|
||||
disabled
|
||||
/>
|
||||
<UpdateDatabasePassword
|
||||
onUpdatePassword={async (newPassword) => {
|
||||
await changePassword({
|
||||
postgresId,
|
||||
password: newPassword,
|
||||
});
|
||||
toast.success("Password updated successfully");
|
||||
utils.postgres.one.invalidate({ postgresId });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { slugify } from "@/lib/slug";
|
||||
import { api } from "@/utils/api";
|
||||
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
|
||||
|
||||
const AddTemplateSchema = z.object({
|
||||
name: z.string().min(1, {
|
||||
@@ -54,8 +53,9 @@ const AddTemplateSchema = z.object({
|
||||
.min(1, {
|
||||
message: "App name is required",
|
||||
})
|
||||
.regex(APP_NAME_REGEX, {
|
||||
message: APP_NAME_MESSAGE,
|
||||
.regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
|
||||
message:
|
||||
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
serverId: z.string().optional(),
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { slugify } from "@/lib/slug";
|
||||
import { api } from "@/utils/api";
|
||||
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
|
||||
|
||||
const AddComposeSchema = z.object({
|
||||
composeType: z.enum(["docker-compose", "stack"]).optional(),
|
||||
@@ -55,8 +54,9 @@ const AddComposeSchema = z.object({
|
||||
.min(1, {
|
||||
message: "App name is required",
|
||||
})
|
||||
.regex(APP_NAME_REGEX, {
|
||||
message: APP_NAME_MESSAGE,
|
||||
.regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
|
||||
message:
|
||||
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
serverId: z.string().optional(),
|
||||
@@ -78,6 +78,9 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
const { mutateAsync, isPending, error, isError } =
|
||||
api.compose.create.useMutation();
|
||||
|
||||
// Get environment data to extract projectId
|
||||
// const { data: environment } = api.environment.one.useQuery({ environmentId });
|
||||
|
||||
const hasServers = servers && servers.length > 0;
|
||||
// Show dropdown logic based on cloud environment
|
||||
// Cloud: show only if there are remote servers (no Dokploy option)
|
||||
|
||||
@@ -52,13 +52,12 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { slugify } from "@/lib/slug";
|
||||
import { api } from "@/utils/api";
|
||||
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
|
||||
|
||||
type DbType = z.infer<typeof mySchema>["type"];
|
||||
|
||||
const dockerImageDefaultPlaceholder: Record<DbType, string> = {
|
||||
mongo: "mongo:8",
|
||||
libsql: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
|
||||
mongo: "mongo:7",
|
||||
mariadb: "mariadb:11",
|
||||
mysql: "mysql:8",
|
||||
postgres: "postgres:18",
|
||||
@@ -83,8 +82,9 @@ const baseDatabaseSchema = z.object({
|
||||
.min(1, {
|
||||
message: "App name is required",
|
||||
})
|
||||
.regex(APP_NAME_REGEX, {
|
||||
message: APP_NAME_MESSAGE,
|
||||
.regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
|
||||
message:
|
||||
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
|
||||
}),
|
||||
databasePassword: z
|
||||
.string()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
BookText,
|
||||
Bookmark,
|
||||
CheckIcon,
|
||||
ChevronsUpDown,
|
||||
Globe,
|
||||
@@ -83,7 +82,6 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed");
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [showBookmarksOnly, setShowBookmarksOnly] = useState(false);
|
||||
const [customBaseUrl, setCustomBaseUrl] = useState<string | undefined>(() => {
|
||||
// Try to get from props first, then localStorage
|
||||
if (baseUrl) return baseUrl;
|
||||
@@ -124,45 +122,8 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
enabled: open,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: bookmarkIds = [], isLoading: isLoadingBookmarks } =
|
||||
api.user.getBookmarkedTemplates.useQuery(undefined, {
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { mutateAsync: toggleBookmark } =
|
||||
api.user.toggleTemplateBookmark.useMutation({
|
||||
onMutate: async ({ templateId }) => {
|
||||
await utils.user.getBookmarkedTemplates.cancel();
|
||||
const previousBookmarks = utils.user.getBookmarkedTemplates.getData();
|
||||
|
||||
utils.user.getBookmarkedTemplates.setData(undefined, (old = []) => {
|
||||
if (old.includes(templateId)) {
|
||||
return old.filter((id) => id !== templateId);
|
||||
}
|
||||
return [...old, templateId];
|
||||
});
|
||||
|
||||
return { previousBookmarks };
|
||||
},
|
||||
onError: (err, variables, context) => {
|
||||
if (context?.previousBookmarks) {
|
||||
utils.user.getBookmarkedTemplates.setData(
|
||||
undefined,
|
||||
context.previousBookmarks,
|
||||
);
|
||||
}
|
||||
toast.error("Failed to update bookmark");
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success(
|
||||
data.isBookmarked ? "Added to bookmarks" : "Removed from bookmarks",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const [serverId, setServerId] = useState<string | undefined>(undefined);
|
||||
const { mutateAsync, isPending, error, isError } =
|
||||
api.compose.deployTemplate.useMutation();
|
||||
@@ -176,9 +137,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
query === "" ||
|
||||
template.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
template.description.toLowerCase().includes(query.toLowerCase());
|
||||
const matchesBookmarks =
|
||||
!showBookmarksOnly || bookmarkIds.includes(template.id);
|
||||
return matchesTags && matchesQuery && matchesBookmarks;
|
||||
return matchesTags && matchesQuery;
|
||||
}) || [];
|
||||
|
||||
const hasServers = servers && servers.length > 0;
|
||||
@@ -187,14 +146,6 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
|
||||
const shouldShowServerDropdown = hasServers;
|
||||
|
||||
const handleToggleBookmark = async (
|
||||
e: React.MouseEvent,
|
||||
templateId: string,
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
await toggleBookmark({ templateId });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger className="w-full">
|
||||
@@ -292,20 +243,6 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
variant={showBookmarksOnly ? "default" : "outline"}
|
||||
size="icon"
|
||||
onClick={() => setShowBookmarksOnly(!showBookmarksOnly)}
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
disabled={isLoadingBookmarks}
|
||||
>
|
||||
<Bookmark
|
||||
className={cn(
|
||||
"size-4",
|
||||
showBookmarksOnly && "fill-current",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
@@ -362,19 +299,11 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="flex flex-col justify-center items-center w-full gap-2 min-h-[50vh]">
|
||||
<div className="flex justify-center items-center w-full gap-2 min-h-[50vh]">
|
||||
<SearchIcon className="text-muted-foreground size-6" />
|
||||
<div className="text-xl font-medium text-muted-foreground">
|
||||
{showBookmarksOnly
|
||||
? "No bookmarked templates found"
|
||||
: "No templates found"}
|
||||
No templates found
|
||||
</div>
|
||||
{showBookmarksOnly && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click the bookmark icon on templates to add them to
|
||||
bookmarks
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
@@ -394,25 +323,9 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
viewMode === "detailed" && "h-[400px]",
|
||||
)}
|
||||
>
|
||||
<div className="absolute top-2 left-2 z-10">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 bg-background/80 backdrop-blur-sm hover:bg-background"
|
||||
onClick={(e) => handleToggleBookmark(e, template.id)}
|
||||
>
|
||||
<Bookmark
|
||||
className={cn(
|
||||
"size-4",
|
||||
bookmarkIds.includes(template.id) &&
|
||||
"fill-yellow-400 text-yellow-400",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="absolute top-2 right-2">
|
||||
<Badge variant="blue">{template?.version}</Badge>
|
||||
</div>
|
||||
<Badge className="absolute top-2 right-2" variant="blue">
|
||||
{template?.version}
|
||||
</Badge>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-none p-6 pb-3 flex flex-col items-center gap-4 bg-muted/30",
|
||||
|
||||
@@ -298,19 +298,7 @@ export const TemplateGenerator = ({ environmentId }: Props) => {
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2 w-full justify-end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (
|
||||
stepper.current.id === "variant" &&
|
||||
templateInfo.details
|
||||
) {
|
||||
setTemplateInfo((prev) => ({
|
||||
...prev,
|
||||
details: null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
stepper.prev();
|
||||
}}
|
||||
onClick={stepper.prev}
|
||||
disabled={stepper.isFirst}
|
||||
variant="secondary"
|
||||
>
|
||||
|
||||
@@ -88,12 +88,7 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
|
||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
e.code === "KeyS" &&
|
||||
!isPending &&
|
||||
isOpen
|
||||
) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending && isOpen) {
|
||||
e.preventDefault();
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
|
||||
@@ -87,12 +87,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
|
||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
e.code === "KeyS" &&
|
||||
!isPending &&
|
||||
isOpen
|
||||
) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending && isOpen) {
|
||||
e.preventDefault();
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
redisId: string;
|
||||
}
|
||||
export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
|
||||
const { data } = api.redis.one.useQuery({ redisId });
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync: changePassword } =
|
||||
api.redis.changePassword.useMutation();
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
@@ -29,21 +24,11 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Password</Label>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<div className="flex flex-row gap-4">
|
||||
<ToggleVisibilityInput
|
||||
value={data?.databasePassword}
|
||||
disabled
|
||||
/>
|
||||
<UpdateDatabasePassword
|
||||
onUpdatePassword={async (newPassword) => {
|
||||
await changePassword({
|
||||
redisId,
|
||||
password: newPassword,
|
||||
});
|
||||
toast.success("Password updated successfully");
|
||||
utils.redis.one.invalidate({ redisId });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -63,7 +63,7 @@ export const SearchCommand = () => {
|
||||
|
||||
React.useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.code === "KeyJ" && (e.metaKey || e.ctrlKey)) {
|
||||
if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setOpen((open) => !open);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@ import { loadStripe } from "@stripe/stripe-js";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
CheckIcon,
|
||||
CreditCard,
|
||||
FileText,
|
||||
Loader2,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -26,17 +24,7 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { NumberInput } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
@@ -101,8 +89,6 @@ export const ShowBilling = () => {
|
||||
api.stripe.createCustomerPortalSession.useMutation();
|
||||
const { mutateAsync: upgradeSubscription, isPending: isUpgrading } =
|
||||
api.stripe.upgradeSubscription.useMutation();
|
||||
const { mutateAsync: updateInvoiceNotifications } =
|
||||
api.stripe.updateInvoiceNotifications.useMutation();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
|
||||
@@ -155,7 +141,6 @@ export const ShowBilling = () => {
|
||||
return isAnnual ? interval === "year" : interval === "month";
|
||||
});
|
||||
|
||||
const isEnterpriseCloud = admin?.user.isEnterpriseCloud ?? false;
|
||||
const maxServers = admin?.user.serversQuantity ?? 1;
|
||||
const percentage = ((servers ?? 0) / maxServers) * 100;
|
||||
const safePercentage = Math.min(percentage, 100);
|
||||
@@ -164,66 +149,14 @@ export const ShowBilling = () => {
|
||||
<div className="w-full">
|
||||
<Card className="bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader className="flex flex-row items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<CreditCard className="size-6 text-muted-foreground self-center" />
|
||||
Billing
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your subscription and invoices
|
||||
</CardDescription>
|
||||
</div>
|
||||
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Bell className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Notification Settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure your billing email notifications.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="invoice-notifications">
|
||||
Invoice Notifications
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Receive email notifications for payments and failed
|
||||
charges.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="invoice-notifications"
|
||||
checked={admin?.user.sendInvoiceNotifications ?? false}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updateInvoiceNotifications({
|
||||
enabled: checked,
|
||||
})
|
||||
.then(() => {
|
||||
utils.user.get.invalidate();
|
||||
toast.success(
|
||||
checked
|
||||
? "Invoice notifications enabled"
|
||||
: "Invoice notifications disabled",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Failed to update invoice notifications",
|
||||
);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<CreditCard className="size-6 text-muted-foreground self-center" />
|
||||
Billing
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your subscription and invoices
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 py-4 border-t">
|
||||
<nav className="flex space-x-2 border-b">
|
||||
@@ -249,7 +182,7 @@ export const ShowBilling = () => {
|
||||
</nav>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full mt-6">
|
||||
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
|
||||
{admin?.user.stripeSubscriptionId && (
|
||||
<div className="space-y-2 flex flex-col">
|
||||
<h3 className="text-lg font-medium">Servers Plan</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -270,36 +203,8 @@ export const ShowBilling = () => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isEnterpriseCloud && (
|
||||
<div className="flex items-start gap-3 rounded-xl border border-primary/30 bg-primary/5 p-4 max-w-2xl">
|
||||
<ShieldCheck className="h-6 w-6 text-primary shrink-0 mt-0.5" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
Enterprise Cloud Plan
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your organization is on a managed Enterprise plan. Billing
|
||||
is handled separately — contact your account manager for
|
||||
any changes.
|
||||
</p>
|
||||
{admin?.user.stripeCustomerId && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-fit mt-2"
|
||||
onClick={async () => {
|
||||
const session = await createCustomerPortalSession();
|
||||
window.open(session.url);
|
||||
}}
|
||||
>
|
||||
Manage Subscription
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Upgrade: solo para usuarios en plan legacy con nuevos planes disponibles */}
|
||||
{!isEnterpriseCloud &&
|
||||
useNewPricing &&
|
||||
{useNewPricing &&
|
||||
data?.currentPlan === "legacy" &&
|
||||
data?.subscriptions?.length > 0 && (
|
||||
<div className="rounded-xl border border-border bg-primary/5 p-4 space-y-4 max-w-2xl">
|
||||
@@ -489,8 +394,7 @@ export const ShowBilling = () => {
|
||||
</div>
|
||||
)}
|
||||
{/* Cambiar plan o cantidad de servidores (usuarios en Hobby o Startup; el portal no permite esto) */}
|
||||
{!isEnterpriseCloud &&
|
||||
useNewPricing &&
|
||||
{useNewPricing &&
|
||||
(data?.currentPlan === "hobby" ||
|
||||
data?.currentPlan === "startup") &&
|
||||
data?.subscriptions?.length > 0 && (
|
||||
@@ -875,18 +779,17 @@ export const ShowBilling = () => {
|
||||
Manage Subscription
|
||||
</Button>
|
||||
)}
|
||||
{!isEnterpriseCloud &&
|
||||
(data?.subscriptions?.length ?? 0) === 0 && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
handleCheckout("hobby", data!.hobbyProductId!)
|
||||
}
|
||||
disabled={hobbyServerQuantity < 1}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
)}
|
||||
{(data?.subscriptions?.length ?? 0) === 0 && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
handleCheckout("hobby", data!.hobbyProductId!)
|
||||
}
|
||||
disabled={hobbyServerQuantity < 1}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1020,24 +923,22 @@ export const ShowBilling = () => {
|
||||
Manage Subscription
|
||||
</Button>
|
||||
)}
|
||||
{!isEnterpriseCloud &&
|
||||
(data?.subscriptions?.length ?? 0) === 0 && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
handleCheckout(
|
||||
"startup",
|
||||
data!.startupProductId!,
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
startupServerQuantity <
|
||||
STARTUP_SERVERS_INCLUDED
|
||||
}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
)}
|
||||
{(data?.subscriptions?.length ?? 0) === 0 && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
handleCheckout(
|
||||
"startup",
|
||||
data!.startupProductId!,
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
startupServerQuantity < STARTUP_SERVERS_INCLUDED
|
||||
}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1242,18 +1143,17 @@ export const ShowBilling = () => {
|
||||
Manage Subscription
|
||||
</Button>
|
||||
)}
|
||||
{!isEnterpriseCloud &&
|
||||
(data?.subscriptions?.length ?? 0) === 0 && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
handleCheckout("legacy", product.id);
|
||||
}}
|
||||
disabled={hobbyServerQuantity < 1}
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
)}
|
||||
{(data?.subscriptions?.length ?? 0) === 0 && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
handleCheckout("legacy", product.id);
|
||||
}}
|
||||
disabled={hobbyServerQuantity < 1}
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { HelpCircle, PlusIcon, SquarePen } from "lucide-react";
|
||||
import { HelpCircle, PlusIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -47,157 +47,108 @@ const certificateDataHolder =
|
||||
const privateKeyDataHolder =
|
||||
"-----BEGIN PRIVATE KEY-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n-----END PRIVATE KEY-----";
|
||||
|
||||
const handleCertificateSchema = z.object({
|
||||
const addCertificate = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
certificateData: z.string().min(1, "Certificate data is required"),
|
||||
privateKey: z.string().min(1, "Private key is required"),
|
||||
autoRenew: z.boolean().optional(),
|
||||
serverId: z.string().optional(),
|
||||
});
|
||||
|
||||
type HandleCertificateForm = z.infer<typeof handleCertificateSchema>;
|
||||
type AddCertificate = z.infer<typeof addCertificate>;
|
||||
|
||||
interface Props {
|
||||
certificateId?: string;
|
||||
}
|
||||
|
||||
export const HandleCertificate = ({ certificateId }: Props) => {
|
||||
export const AddCertificate = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { mutateAsync, isError, error, isPending } =
|
||||
api.certificates.create.useMutation();
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
const hasServers = servers && servers.length > 0;
|
||||
const shouldShowServerDropdown = hasServers && !certificateId; // Hide on edit
|
||||
// Show dropdown logic based on cloud environment
|
||||
// Cloud: show only if there are remote servers (no Dokploy option)
|
||||
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
|
||||
const shouldShowServerDropdown = hasServers;
|
||||
|
||||
const { data: existingCert, refetch } = api.certificates.one.useQuery(
|
||||
{ certificateId: certificateId || "" },
|
||||
{ enabled: !!certificateId },
|
||||
);
|
||||
|
||||
const createMutation = api.certificates.create.useMutation();
|
||||
const updateMutation = api.certificates.update.useMutation();
|
||||
const mutation = certificateId ? updateMutation : createMutation;
|
||||
const { mutateAsync, isError, error, isPending } = mutation;
|
||||
|
||||
const form = useForm<HandleCertificateForm>({
|
||||
const form = useForm<AddCertificate>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
certificateData: "",
|
||||
privateKey: "",
|
||||
autoRenew: false,
|
||||
},
|
||||
resolver: zodResolver(handleCertificateSchema),
|
||||
resolver: zodResolver(addCertificate),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (existingCert) {
|
||||
form.reset({
|
||||
name: existingCert.name,
|
||||
certificateData: existingCert.certificateData,
|
||||
privateKey: existingCert.privateKey,
|
||||
});
|
||||
} else {
|
||||
form.reset({
|
||||
name: "",
|
||||
certificateData: "",
|
||||
privateKey: "",
|
||||
});
|
||||
}
|
||||
}, [existingCert, form, open]);
|
||||
form.reset();
|
||||
}, [form, form.formState.isSubmitSuccessful, form.reset]);
|
||||
|
||||
const onSubmit = async (data: HandleCertificateForm) => {
|
||||
const basePayload = {
|
||||
const onSubmit = async (data: AddCertificate) => {
|
||||
await mutateAsync({
|
||||
name: data.name,
|
||||
certificateData: data.certificateData,
|
||||
privateKey: data.privateKey,
|
||||
};
|
||||
|
||||
const promise = certificateId
|
||||
? updateMutation.mutateAsync({
|
||||
certificateId,
|
||||
...basePayload,
|
||||
})
|
||||
: createMutation.mutateAsync({
|
||||
...basePayload,
|
||||
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
|
||||
organizationId: "",
|
||||
});
|
||||
|
||||
await promise
|
||||
autoRenew: data.autoRenew,
|
||||
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
|
||||
organizationId: "",
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success(
|
||||
certificateId ? "Certificate Updated" : "Certificate Created",
|
||||
);
|
||||
toast.success("Certificate Created");
|
||||
await utils.certificates.all.invalidate();
|
||||
if (certificateId) {
|
||||
refetch();
|
||||
}
|
||||
setOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
certificateId
|
||||
? "Error updating the Certificate"
|
||||
: "Error creating the Certificate",
|
||||
);
|
||||
toast.error("Error creating the Certificate");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{certificateId ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10"
|
||||
>
|
||||
<SquarePen className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Certificate
|
||||
</Button>
|
||||
)}
|
||||
<DialogTrigger className="" asChild>
|
||||
<Button>
|
||||
{" "}
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Certificate
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{certificateId ? "Update" : "Add New"} Certificate
|
||||
</DialogTitle>
|
||||
<DialogTitle>Add New Certificate</DialogTitle>
|
||||
<DialogDescription>
|
||||
{certificateId
|
||||
? "Modify the certificate details"
|
||||
: "Upload or generate a certificate to secure your application"}
|
||||
Upload or generate a certificate to secure your application
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-handle-certificate"
|
||||
id="hook-form-add-certificate"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
className="grid w-full gap-4 "
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Certificate Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Certificate" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Certificate Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"My Certificate"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="certificateData"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Certificate Data</FormLabel>
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Certificate Data</FormLabel>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="h-32"
|
||||
@@ -214,7 +165,9 @@ export const HandleCertificate = ({ certificateId }: Props) => {
|
||||
name="privateKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Private Key</FormLabel>
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Private Key</FormLabel>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="h-32"
|
||||
@@ -295,10 +248,10 @@ export const HandleCertificate = ({ certificateId }: Props) => {
|
||||
<DialogFooter className="flex w-full flex-row !justify-end">
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
form="hook-form-handle-certificate"
|
||||
form="hook-form-add-certificate"
|
||||
type="submit"
|
||||
>
|
||||
{certificateId ? "Update" : "Create"}
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
@@ -1,14 +1,4 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Link,
|
||||
Loader2,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { AlertCircle, Link, Loader2, ShieldCheck, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
@@ -21,20 +11,14 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { HandleCertificate } from "./handle-certificate";
|
||||
import {
|
||||
extractLeafCommonName,
|
||||
getCertificateChainExpirationDetails,
|
||||
getCertificateChainInfo,
|
||||
getExpirationStatus,
|
||||
} from "./utils";
|
||||
import { AddCertificate } from "./add-certificate";
|
||||
import { getCertificateChainInfo, getExpirationStatus } from "./utils";
|
||||
|
||||
export const ShowCertificates = () => {
|
||||
const { mutateAsync, isPending: isRemoving } =
|
||||
api.certificates.remove.useMutation();
|
||||
const { data, isPending, refetch } = api.certificates.all.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const [expandedChains, setExpandedChains] = useState<Set<string>>(new Set());
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -70,7 +54,7 @@ export const ShowCertificates = () => {
|
||||
<span className="text-base text-muted-foreground text-center">
|
||||
You don't have any certificates created
|
||||
</span>
|
||||
{permissions?.certificate.create && <HandleCertificate />}
|
||||
{permissions?.certificate.create && <AddCertificate />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
@@ -82,30 +66,6 @@ export const ShowCertificates = () => {
|
||||
const chainInfo = getCertificateChainInfo(
|
||||
certificate.certificateData,
|
||||
);
|
||||
const commonName = extractLeafCommonName(
|
||||
certificate.certificateData,
|
||||
);
|
||||
const chainDetails = chainInfo.isChain
|
||||
? getCertificateChainExpirationDetails(
|
||||
certificate.certificateData,
|
||||
)
|
||||
: null;
|
||||
const isExpanded = expandedChains.has(
|
||||
certificate.certificateId,
|
||||
);
|
||||
|
||||
const toggleChain = () => {
|
||||
setExpandedChains((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(certificate.certificateId)) {
|
||||
next.delete(certificate.certificateId);
|
||||
} else {
|
||||
next.add(certificate.certificateId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={certificate.certificateId}
|
||||
@@ -117,58 +77,12 @@ export const ShowCertificates = () => {
|
||||
<span className="text-sm font-medium">
|
||||
{index + 1}. {certificate.name}
|
||||
</span>
|
||||
{commonName && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
CN: {commonName}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Server className="size-3" />
|
||||
{certificate.server
|
||||
? `${certificate.server.name} (${certificate.server.ipAddress})`
|
||||
: "Dokploy (Local)"}
|
||||
</span>
|
||||
{chainInfo.isChain && (
|
||||
<div className="flex flex-col gap-1.5 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleChain}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50 w-fit hover:bg-muted transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
<Link className="size-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Chain ({chainInfo.count} certificates)
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="flex flex-col gap-3 pl-2 border-l-2 border-muted">
|
||||
{chainDetails?.map((cert) => (
|
||||
<div
|
||||
key={cert.index}
|
||||
className="flex flex-col gap-1 p-2 rounded-md bg-muted/30"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{cert.label}
|
||||
</span>
|
||||
{cert.commonName && (
|
||||
<span className="text-xs text-muted-foreground/80">
|
||||
CN: {cert.commonName}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`text-xs ${cert.className}`}
|
||||
>
|
||||
{cert.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50">
|
||||
<Link className="size-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Chain ({chainInfo.count})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
@@ -188,14 +102,8 @@ export const ShowCertificates = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-1">
|
||||
{permissions?.certificate.update && (
|
||||
<HandleCertificate
|
||||
certificateId={certificate.certificateId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{permissions?.certificate.delete && (
|
||||
{permissions?.certificate.delete && (
|
||||
<div className="flex flex-row gap-1">
|
||||
<DialogAction
|
||||
title="Delete Certificate"
|
||||
description="Are you sure you want to delete this certificate?"
|
||||
@@ -221,14 +129,14 @@ export const ShowCertificates = () => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -237,7 +145,7 @@ export const ShowCertificates = () => {
|
||||
|
||||
{permissions?.certificate.create && (
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||
<HandleCertificate />
|
||||
<AddCertificate />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
// @ts-nocheck
|
||||
|
||||
// Split certificate chain into individual certificates
|
||||
export const splitCertificateChain = (certData: string): string[] => {
|
||||
const certRegex =
|
||||
/(-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----)/g;
|
||||
const matches = certData.match(certRegex);
|
||||
return matches || [];
|
||||
};
|
||||
|
||||
export const extractExpirationDate = (certData: string): Date | null => {
|
||||
try {
|
||||
// Decode PEM base64 to DER binary
|
||||
@@ -36,11 +28,11 @@ export const extractExpirationDate = (certData: string): Date | null => {
|
||||
}
|
||||
|
||||
// Skip the outer certificate sequence
|
||||
if (der[offset++] !== 0x30) return null;
|
||||
if (der[offset++] !== 0x30) throw new Error("Expected sequence");
|
||||
({ offset } = readLength(offset));
|
||||
|
||||
// Skip tbsCertificate sequence
|
||||
if (der[offset++] !== 0x30) return null;
|
||||
if (der[offset++] !== 0x30) throw new Error("Expected tbsCertificate");
|
||||
({ offset } = readLength(offset));
|
||||
|
||||
// Check for optional version field (context-specific tag [0])
|
||||
@@ -52,14 +44,15 @@ export const extractExpirationDate = (certData: string): Date | null => {
|
||||
|
||||
// Skip serialNumber, signature, issuer
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (der[offset] !== 0x30 && der[offset] !== 0x02) return null;
|
||||
if (der[offset] !== 0x30 && der[offset] !== 0x02)
|
||||
throw new Error("Unexpected structure");
|
||||
offset++;
|
||||
const fieldLen = readLength(offset);
|
||||
offset = fieldLen.offset + fieldLen.length;
|
||||
}
|
||||
|
||||
// Validity sequence (notBefore and notAfter)
|
||||
if (der[offset++] !== 0x30) return null;
|
||||
if (der[offset++] !== 0x30) throw new Error("Expected validity sequence");
|
||||
const validityLen = readLength(offset);
|
||||
offset = validityLen.offset;
|
||||
|
||||
@@ -101,156 +94,8 @@ export const extractExpirationDate = (certData: string): Date | null => {
|
||||
}
|
||||
};
|
||||
|
||||
export const extractCommonName = (certData: string): string | null => {
|
||||
try {
|
||||
// Decode PEM base64 to DER binary
|
||||
const b64 = certData.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "");
|
||||
const binStr = atob(b64);
|
||||
const der = new Uint8Array(binStr.length);
|
||||
for (let i = 0; i < binStr.length; i++) {
|
||||
der[i] = binStr.charCodeAt(i);
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
|
||||
// Helper: read ASN.1 length field
|
||||
function readLength(pos: number): { length: number; offset: number } {
|
||||
// biome-ignore lint/style/noParameterAssign: <explanation>
|
||||
let len = der[pos++];
|
||||
if (len & 0x80) {
|
||||
const bytes = len & 0x7f;
|
||||
len = 0;
|
||||
for (let i = 0; i < bytes; i++) {
|
||||
// biome-ignore lint/style/noParameterAssign: <explanation>
|
||||
len = (len << 8) + der[pos++];
|
||||
}
|
||||
}
|
||||
return { length: len, offset: pos };
|
||||
}
|
||||
|
||||
// Helper: skip a field
|
||||
function skipField(pos: number): number {
|
||||
// biome-ignore lint/style/noParameterAssign: <explanation>
|
||||
pos++;
|
||||
const fieldLen = readLength(pos);
|
||||
return fieldLen.offset + fieldLen.length;
|
||||
}
|
||||
|
||||
// Skip the outer certificate sequence
|
||||
if (der[offset++] !== 0x30) return null;
|
||||
({ offset } = readLength(offset));
|
||||
|
||||
// Skip tbsCertificate sequence
|
||||
if (der[offset++] !== 0x30) return null;
|
||||
({ offset } = readLength(offset));
|
||||
|
||||
// Check for optional version field (context-specific tag [0])
|
||||
if (der[offset] === 0xa0) {
|
||||
offset++;
|
||||
const versionLen = readLength(offset);
|
||||
offset = versionLen.offset + versionLen.length;
|
||||
}
|
||||
|
||||
// Skip serialNumber
|
||||
offset = skipField(offset);
|
||||
|
||||
// Skip signature
|
||||
offset = skipField(offset);
|
||||
|
||||
// Skip issuer
|
||||
offset = skipField(offset);
|
||||
|
||||
// Skip validity
|
||||
offset = skipField(offset);
|
||||
|
||||
// Subject sequence - where we find the CN
|
||||
if (der[offset++] !== 0x30) return null;
|
||||
const subjectLen = readLength(offset);
|
||||
const subjectEnd = subjectLen.offset + subjectLen.length;
|
||||
offset = subjectLen.offset;
|
||||
|
||||
// Parse subject RDNs looking for CN (OID 2.5.4.3)
|
||||
while (offset < subjectEnd) {
|
||||
if (der[offset++] !== 0x31) continue; // SET
|
||||
const setLen = readLength(offset);
|
||||
offset = setLen.offset;
|
||||
|
||||
if (der[offset++] !== 0x30) continue; // SEQUENCE
|
||||
const seqLen = readLength(offset);
|
||||
offset = seqLen.offset;
|
||||
|
||||
if (der[offset++] !== 0x06) continue; // OID
|
||||
const oidLen = readLength(offset);
|
||||
offset = oidLen.offset;
|
||||
|
||||
// Check if OID is 2.5.4.3 (commonName)
|
||||
const oid = Array.from(der.slice(offset, offset + oidLen.length));
|
||||
offset += oidLen.length;
|
||||
|
||||
// OID 2.5.4.3 in DER: [0x55, 0x04, 0x03]
|
||||
if (
|
||||
oid.length === 3 &&
|
||||
oid[0] === 0x55 &&
|
||||
oid[1] === 0x04 &&
|
||||
oid[2] === 0x03
|
||||
) {
|
||||
// Next should be the string value
|
||||
const strType = der[offset++];
|
||||
const strLen = readLength(offset);
|
||||
const cnBytes = der.slice(strLen.offset, strLen.offset + strLen.length);
|
||||
return new TextDecoder().decode(cnBytes);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Error parsing certificate CN:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Extract the Common Name from the first (leaf) certificate in a chain
|
||||
export const extractLeafCommonName = (certData: string): string | null => {
|
||||
const certs = splitCertificateChain(certData);
|
||||
if (certs.length === 0) return null;
|
||||
return extractCommonName(certs[0]);
|
||||
};
|
||||
|
||||
// Extract expiration dates from all certificates in a chain
|
||||
export const extractAllExpirationDates = (
|
||||
certData: string,
|
||||
): Array<{
|
||||
cert: string;
|
||||
index: number;
|
||||
expirationDate: Date | null;
|
||||
commonName: string | null;
|
||||
}> => {
|
||||
const certs = splitCertificateChain(certData);
|
||||
return certs.map((cert, index) => ({
|
||||
cert,
|
||||
index,
|
||||
expirationDate: extractExpirationDate(cert),
|
||||
commonName: extractCommonName(cert),
|
||||
}));
|
||||
};
|
||||
|
||||
// Get the earliest expiration date from a certificate chain
|
||||
export const getEarliestExpirationDate = (certData: string): Date | null => {
|
||||
const expirationDates = extractAllExpirationDates(certData);
|
||||
const validDates = expirationDates
|
||||
.filter((item) => item.expirationDate !== null)
|
||||
.map((item) => item.expirationDate as Date);
|
||||
|
||||
if (validDates.length === 0) return null;
|
||||
|
||||
return new Date(Math.min(...validDates.map((date) => date.getTime())));
|
||||
};
|
||||
|
||||
export const getExpirationStatus = (certData: string) => {
|
||||
const chainInfo = getCertificateChainInfo(certData);
|
||||
const expirationDate = chainInfo.isChain
|
||||
? getEarliestExpirationDate(certData)
|
||||
: extractExpirationDate(certData);
|
||||
const expirationDate = extractExpirationDate(certData);
|
||||
|
||||
if (!expirationDate)
|
||||
return {
|
||||
@@ -308,67 +153,3 @@ export const getCertificateChainInfo = (certData: string) => {
|
||||
count: 1,
|
||||
};
|
||||
};
|
||||
|
||||
// Get detailed expiration information for all certificates in a chain
|
||||
export const getCertificateChainExpirationDetails = (certData: string) => {
|
||||
const allExpirations = extractAllExpirationDates(certData);
|
||||
const now = new Date();
|
||||
|
||||
return allExpirations.map(({ index, expirationDate, commonName }) => {
|
||||
if (!expirationDate) {
|
||||
return {
|
||||
index,
|
||||
label: `Certificate ${index + 1}`,
|
||||
commonName,
|
||||
status: "unknown" as const,
|
||||
className: "text-muted-foreground",
|
||||
message: "Could not determine expiration",
|
||||
expirationDate: null,
|
||||
};
|
||||
}
|
||||
|
||||
const daysUntilExpiration = Math.ceil(
|
||||
(expirationDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
let status: "expired" | "warning" | "valid";
|
||||
let className: string;
|
||||
let message: string;
|
||||
|
||||
if (daysUntilExpiration < 0) {
|
||||
status = "expired";
|
||||
className = "text-red-500";
|
||||
message = `Expired on ${expirationDate.toLocaleDateString([], {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}`;
|
||||
} else if (daysUntilExpiration <= 30) {
|
||||
status = "warning";
|
||||
className = "text-yellow-500";
|
||||
message = `Expires in ${daysUntilExpiration} days`;
|
||||
} else {
|
||||
status = "valid";
|
||||
className = "text-muted-foreground";
|
||||
message = `Expires ${expirationDate.toLocaleDateString([], {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}`;
|
||||
}
|
||||
|
||||
return {
|
||||
index,
|
||||
label:
|
||||
index === 0
|
||||
? `Certificate ${index + 1} (Leaf)`
|
||||
: `Certificate ${index + 1}`,
|
||||
commonName,
|
||||
status,
|
||||
className,
|
||||
message,
|
||||
expirationDate,
|
||||
daysUntilExpiration,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
ADDITIONAL_FLAG_ERROR,
|
||||
ADDITIONAL_FLAG_REGEX,
|
||||
} from "@dokploy/server/db/validations/destination";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon, PlusIcon, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -39,6 +35,10 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
ADDITIONAL_FLAG_ERROR,
|
||||
ADDITIONAL_FLAG_REGEX,
|
||||
} from "@dokploy/server/db/validations/destination";
|
||||
import { S3_PROVIDERS } from "./constants";
|
||||
|
||||
const addDestination = z.object({
|
||||
|
||||
@@ -283,7 +283,7 @@ export const AddGitlabProvider = () => {
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="For organization/group access use the slug name of the group eg: my-org"
|
||||
placeholder="For organization/group access use the slugish name of the group eg: my-org"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -192,7 +192,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="For organization/group access use the slug name of the group eg: my-org"
|
||||
placeholder="For organization/group access use the slugish name of the group eg: my-org"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
ImportIcon,
|
||||
Loader2,
|
||||
Trash2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
@@ -25,13 +24,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { useUrl } from "@/utils/hooks/use-url";
|
||||
import { AddBitbucketProvider } from "./bitbucket/add-bitbucket-provider";
|
||||
@@ -47,8 +39,6 @@ export const ShowGitProviders = () => {
|
||||
const { data, isPending, refetch } = api.gitProvider.getAll.useQuery();
|
||||
const { mutateAsync, isPending: isRemoving } =
|
||||
api.gitProvider.remove.useMutation();
|
||||
const { mutateAsync: toggleShare, isPending: isToggling } =
|
||||
api.gitProvider.toggleShare.useMutation();
|
||||
const url = useUrl();
|
||||
|
||||
const getGitlabUrl = (
|
||||
@@ -164,62 +154,10 @@ export const ShowGitProviders = () => {
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{!gitProvider.isOwner && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
>
|
||||
<Users className="size-3 mr-1" />
|
||||
Shared
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-1 items-center">
|
||||
{gitProvider.isOwner && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 mr-2">
|
||||
<Users className="size-4 text-muted-foreground" />
|
||||
<Switch
|
||||
disabled={isToggling}
|
||||
checked={
|
||||
gitProvider.sharedWithOrganization
|
||||
}
|
||||
onCheckedChange={async (
|
||||
checked,
|
||||
) => {
|
||||
await toggleShare({
|
||||
gitProviderId:
|
||||
gitProvider.gitProviderId,
|
||||
sharedWithOrganization: checked,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
checked
|
||||
? "Provider shared with organization"
|
||||
: "Provider unshared",
|
||||
);
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error updating sharing",
|
||||
);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Share with entire organization
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{isBitbucket &&
|
||||
gitProvider.bitbucket?.appPassword &&
|
||||
!gitProvider.bitbucket?.apiToken ? (
|
||||
@@ -284,71 +222,62 @@ export const ShowGitProviders = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gitProvider.isOwner && (
|
||||
<>
|
||||
{isGithub && haveGithubRequirements && (
|
||||
<EditGithubProvider
|
||||
githubId={gitProvider.github?.githubId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGitlab && (
|
||||
<EditGitlabProvider
|
||||
gitlabId={gitProvider.gitlab?.gitlabId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isBitbucket && (
|
||||
<EditBitbucketProvider
|
||||
bitbucketId={
|
||||
gitProvider.bitbucket?.bitbucketId
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGitea && (
|
||||
<EditGiteaProvider
|
||||
giteaId={gitProvider.gitea?.giteaId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogAction
|
||||
title="Delete Git Provider"
|
||||
description={
|
||||
gitProvider.sharedWithOrganization
|
||||
? "This provider is shared with the organization. Deleting it will remove access for all members. Are you sure?"
|
||||
: "Are you sure you want to delete this Git Provider?"
|
||||
}
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
gitProviderId:
|
||||
gitProvider.gitProviderId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"Git Provider deleted successfully",
|
||||
);
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error deleting Git Provider",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</>
|
||||
{isGithub && haveGithubRequirements && (
|
||||
<EditGithubProvider
|
||||
githubId={gitProvider.github?.githubId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGitlab && (
|
||||
<EditGitlabProvider
|
||||
gitlabId={gitProvider.gitlab?.gitlabId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isBitbucket && (
|
||||
<EditBitbucketProvider
|
||||
bitbucketId={
|
||||
gitProvider.bitbucket?.bitbucketId
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGitea && (
|
||||
<EditGiteaProvider
|
||||
giteaId={gitProvider.gitea?.giteaId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogAction
|
||||
title="Delete Git Provider"
|
||||
description="Are you sure you want to delete this Git Provider?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
gitProviderId: gitProvider.gitProviderId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"Git Provider deleted successfully",
|
||||
);
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error deleting Git Provider",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
"use client";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
PenBoxIcon,
|
||||
Plug,
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import { Check, ChevronDown, PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -44,34 +37,10 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const AI_PROVIDERS = [
|
||||
{ name: "OpenAI", apiUrl: "https://api.openai.com/v1" },
|
||||
{ name: "Anthropic", apiUrl: "https://api.anthropic.com/v1" },
|
||||
{
|
||||
name: "Google Gemini",
|
||||
apiUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||||
},
|
||||
{ name: "Mistral", apiUrl: "https://api.mistral.ai/v1" },
|
||||
{ name: "Cohere", apiUrl: "https://api.cohere.ai/v2" },
|
||||
{ name: "Perplexity", apiUrl: "https://api.perplexity.ai" },
|
||||
{ name: "DeepInfra", apiUrl: "https://api.deepinfra.com/v1/openai" },
|
||||
{ name: "Ollama", apiUrl: "http://localhost:11434" },
|
||||
{ name: "OpenRouter", apiUrl: "https://openrouter.ai/api/v1" },
|
||||
{ name: "Z.AI", apiUrl: "https://api.z.ai/api/paas/v4" },
|
||||
{ name: "MiniMax", apiUrl: "https://api.minimax.io/v1" },
|
||||
] as const;
|
||||
|
||||
const Schema = z.object({
|
||||
name: z.string().min(1, { message: "Name is required" }),
|
||||
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
|
||||
@@ -134,7 +103,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
const isOllama = apiUrl.includes(":11434") || apiUrl.includes("ollama");
|
||||
const {
|
||||
data: models,
|
||||
isFetching: isLoadingServerModels,
|
||||
isPending: isLoadingServerModels,
|
||||
error: modelsError,
|
||||
} = api.ai.getModels.useQuery(
|
||||
{
|
||||
@@ -203,34 +172,6 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
<AlertBlock type="error">{modelsError.message}</AlertBlock>
|
||||
)}
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>Provider</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
const provider = AI_PROVIDERS.find((p) => p.apiUrl === value);
|
||||
if (provider) {
|
||||
form.setValue("name", provider.name);
|
||||
form.setValue("apiUrl", provider.apiUrl);
|
||||
form.setValue("model", "");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider preset..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AI_PROVIDERS.map((provider) => (
|
||||
<SelectItem key={provider.apiUrl} value={provider.apiUrl}>
|
||||
{provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
Quick-fill provider name and URL, or configure manually below
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
@@ -312,129 +253,101 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
</span>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model"
|
||||
render={({ field }) => {
|
||||
const hasModels =
|
||||
!isLoadingServerModels && models && models.length > 0;
|
||||
const selectedModel = models?.find((m) => m.id === field.value);
|
||||
const filteredModels = (models ?? []).filter((model) =>
|
||||
model.id.toLowerCase().includes(modelSearch.toLowerCase()),
|
||||
);
|
||||
{!isLoadingServerModels && !models?.length && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No models available
|
||||
</span>
|
||||
)}
|
||||
|
||||
const displayModels =
|
||||
field.value &&
|
||||
!filteredModels.find((m) => m.id === field.value) &&
|
||||
selectedModel
|
||||
? [selectedModel, ...filteredModels]
|
||||
: filteredModels;
|
||||
{!isLoadingServerModels && models && models.length > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model"
|
||||
render={({ field }) => {
|
||||
const selectedModel = models.find(
|
||||
(m) => m.id === field.value,
|
||||
);
|
||||
const filteredModels = models.filter((model) =>
|
||||
model.id.toLowerCase().includes(modelSearch.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Model</FormLabel>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
{hasModels ? (
|
||||
<Popover
|
||||
open={modelPopoverOpen}
|
||||
onOpenChange={setModelPopoverOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? (selectedModel?.id ?? field.value)
|
||||
: "Select a model"}
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[400px] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search or type a custom model..."
|
||||
value={modelSearch}
|
||||
onValueChange={setModelSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{modelSearch ? (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full cursor-pointer px-2 py-1.5 text-left text-sm hover:bg-accent"
|
||||
onClick={() => {
|
||||
field.onChange(modelSearch);
|
||||
setModelPopoverOpen(false);
|
||||
setModelSearch("");
|
||||
}}
|
||||
>
|
||||
Use custom model: "{modelSearch}"
|
||||
</button>
|
||||
) : (
|
||||
"No models found."
|
||||
)}
|
||||
</CommandEmpty>
|
||||
{displayModels.map((model) => {
|
||||
const isSelected = field.value === model.id;
|
||||
return (
|
||||
<CommandItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
onSelect={() => {
|
||||
field.onChange(model.id);
|
||||
setModelPopoverOpen(false);
|
||||
setModelSearch("");
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
isSelected
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{model.id}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
// Ensure selected model is always in the filtered list
|
||||
const displayModels =
|
||||
field.value &&
|
||||
!filteredModels.find((m) => m.id === field.value) &&
|
||||
selectedModel
|
||||
? [selectedModel, ...filteredModels]
|
||||
: filteredModels;
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Model</FormLabel>
|
||||
<Popover
|
||||
open={modelPopoverOpen}
|
||||
onOpenChange={setModelPopoverOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={
|
||||
isLoadingServerModels
|
||||
? "Loading models..."
|
||||
: "Enter model name (e.g. gpt-4o)"
|
||||
}
|
||||
disabled={isLoadingServerModels}
|
||||
{...field}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? (selectedModel?.id ?? field.value)
|
||||
: "Select a model"}
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Select a model from the list or type a custom model name
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search models..."
|
||||
value={modelSearch}
|
||||
onValueChange={setModelSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No models found.</CommandEmpty>
|
||||
{displayModels.map((model) => {
|
||||
const isSelected = field.value === model.id;
|
||||
return (
|
||||
<CommandItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
onSelect={() => {
|
||||
field.onChange(model.id);
|
||||
setModelPopoverOpen(false);
|
||||
setModelSearch("");
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
isSelected
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{model.id}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormDescription>
|
||||
Select an AI model to use
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -459,12 +372,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<TestConnectionButton
|
||||
apiUrl={apiUrl}
|
||||
apiKey={apiKey}
|
||||
model={form.watch("model")}
|
||||
/>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{aiId ? "Update" : "Create"}
|
||||
</Button>
|
||||
@@ -475,42 +383,3 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
function TestConnectionButton({
|
||||
apiUrl,
|
||||
apiKey,
|
||||
model,
|
||||
}: {
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
}) {
|
||||
const { mutate, isPending } = api.ai.testConnection.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Connection successful");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Connection failed", {
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isDisabled = !apiUrl || !model;
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isDisabled || isPending}
|
||||
onClick={() => mutate({ apiUrl, apiKey, model })}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plug className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Test Connection
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,9 +12,9 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
DiscordIcon,
|
||||
MattermostIcon,
|
||||
GotifyIcon,
|
||||
LarkIcon,
|
||||
MattermostIcon,
|
||||
NtfyIcon,
|
||||
PushoverIcon,
|
||||
ResendIcon,
|
||||
@@ -54,7 +54,6 @@ const notificationBaseSchema = z.object({
|
||||
appDeploy: z.boolean().default(false),
|
||||
appBuildError: z.boolean().default(false),
|
||||
databaseBackup: z.boolean().default(false),
|
||||
dokployBackup: z.boolean().default(false),
|
||||
volumeBackup: z.boolean().default(false),
|
||||
dokployRestart: z.boolean().default(false),
|
||||
dockerCleanup: z.boolean().default(false),
|
||||
@@ -356,7 +355,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
dokployBackup: notification.dokployBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
webhookUrl: notification.slack?.webhookUrl,
|
||||
@@ -371,7 +369,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
dokployBackup: notification.dokployBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
botToken: notification.telegram?.botToken,
|
||||
messageThreadId: notification.telegram?.messageThreadId || "",
|
||||
@@ -387,7 +384,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
dokployBackup: notification.dokployBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
type: notification.notificationType,
|
||||
webhookUrl: notification.discord?.webhookUrl,
|
||||
@@ -402,7 +398,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
dokployBackup: notification.dokployBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
type: notification.notificationType,
|
||||
smtpServer: notification.email?.smtpServer,
|
||||
@@ -421,7 +416,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
dokployBackup: notification.dokployBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
type: notification.notificationType,
|
||||
apiKey: notification.resend?.apiKey,
|
||||
@@ -437,7 +431,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
dokployBackup: notification.dokployBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
type: notification.notificationType,
|
||||
appToken: notification.gotify?.appToken,
|
||||
@@ -453,7 +446,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
dokployBackup: notification.dokployBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
type: notification.notificationType,
|
||||
accessToken: notification.ntfy?.accessToken || "",
|
||||
@@ -470,7 +462,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
dokployBackup: notification.dokployBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
type: notification.notificationType,
|
||||
webhookUrl: notification.mattermost?.webhookUrl,
|
||||
@@ -486,7 +477,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
dokployBackup: notification.dokployBackup,
|
||||
type: notification.notificationType,
|
||||
webhookUrl: notification.lark?.webhookUrl,
|
||||
name: notification.name,
|
||||
@@ -500,7 +490,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
dokployBackup: notification.dokployBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
type: notification.notificationType,
|
||||
webhookUrl: notification.teams?.webhookUrl,
|
||||
@@ -514,7 +503,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
dokployBackup: notification.dokployBackup,
|
||||
type: notification.notificationType,
|
||||
endpoint: notification.custom?.endpoint || "",
|
||||
headers: notification.custom?.headers
|
||||
@@ -536,7 +524,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
dokployBackup: notification.dokployBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
type: notification.notificationType,
|
||||
userKey: notification.pushover?.userKey,
|
||||
@@ -575,7 +562,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy,
|
||||
dokployRestart,
|
||||
databaseBackup,
|
||||
dokployBackup,
|
||||
volumeBackup,
|
||||
dockerCleanup,
|
||||
serverThreshold,
|
||||
@@ -587,7 +573,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
dokployBackup: dokployBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
webhookUrl: data.webhookUrl,
|
||||
channel: data.channel,
|
||||
@@ -603,7 +588,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
dokployBackup: dokployBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
botToken: data.botToken,
|
||||
messageThreadId: data.messageThreadId || "",
|
||||
@@ -620,7 +604,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
dokployBackup: dokployBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
webhookUrl: data.webhookUrl,
|
||||
decoration: data.decoration,
|
||||
@@ -636,7 +619,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
dokployBackup: dokployBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
smtpServer: data.smtpServer,
|
||||
smtpPort: data.smtpPort,
|
||||
@@ -656,7 +638,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
dokployBackup: dokployBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
apiKey: data.apiKey,
|
||||
fromAddress: data.fromAddress,
|
||||
@@ -673,7 +654,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
dokployBackup: dokployBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
serverUrl: data.serverUrl,
|
||||
appToken: data.appToken,
|
||||
@@ -690,7 +670,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
dokployBackup: dokployBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
serverUrl: data.serverUrl,
|
||||
accessToken: data.accessToken || "",
|
||||
@@ -707,7 +686,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
dokployBackup: dokployBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
webhookUrl: data.webhookUrl,
|
||||
channel: data.channel || undefined,
|
||||
@@ -724,7 +702,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
dokployBackup: dokployBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
webhookUrl: data.webhookUrl,
|
||||
name: data.name,
|
||||
@@ -739,7 +716,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
dokployBackup: dokployBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
webhookUrl: data.webhookUrl,
|
||||
name: data.name,
|
||||
@@ -766,7 +742,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
dokployBackup: dokployBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
endpoint: data.endpoint,
|
||||
headers: headersRecord,
|
||||
@@ -786,7 +761,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
dokployBackup: dokployBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
userKey: data.userKey,
|
||||
apiToken: data.apiToken,
|
||||
@@ -1882,27 +1856,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dokployBackup"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Dokploy Backup</FormLabel>
|
||||
<FormDescription>
|
||||
Trigger the action when a dokploy backup is created.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="volumeBackup"
|
||||
|
||||
@@ -25,7 +25,11 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InputOTP } from "@/components/ui/input-otp";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -419,14 +423,23 @@ export const Enable2FA = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<FormLabel>Verification Code</FormLabel>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={otpValue}
|
||||
onChange={setOtpValue}
|
||||
autoFocus
|
||||
/>
|
||||
autoComplete="off"
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
<FormDescription>
|
||||
Enter the 6-digit code from your authenticator app
|
||||
</FormDescription>
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import { toast } from "sonner";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
serverId?: string;
|
||||
@@ -59,36 +52,7 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Switch checked={!!enabled} onCheckedChange={handleToggle} />
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Label className="text-primary flex items-center gap-1.5 cursor-pointer">
|
||||
Daily Docker Cleanup
|
||||
<HelpCircle className="size-4 text-muted-foreground" />
|
||||
</Label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-sm">
|
||||
<p>
|
||||
Runs a full Docker cleanup daily, pruning stopped containers,
|
||||
unused images, volumes, build cache, and system resources. This
|
||||
may remove images built for Compose services that run on-demand
|
||||
(backup runners, cron jobs, one-off tasks).
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
For custom cleanup strategies, use{" "}
|
||||
<a
|
||||
href="https://docs.dokploy.com/docs/core/schedule-jobs#example-1-automatic-docker-cleanup"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-primary"
|
||||
>
|
||||
Schedule Jobs
|
||||
</a>{" "}
|
||||
on your web server or remote servers.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Label className="text-primary">Daily Docker Cleanup</Label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -161,7 +161,7 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => {
|
||||
<ul>
|
||||
<li>
|
||||
1. Add the public SSH Key when you create a server in your
|
||||
preferred provider (Hostinger, Digital Ocean, Hetzner,
|
||||
preffered provider (Hostinger, Digital Ocean, Hetzner,
|
||||
etc){" "}
|
||||
</li>
|
||||
<li>2. Add The SSH Key to Server Manually</li>
|
||||
|
||||
@@ -48,7 +48,7 @@ import { ShowMonitoringModal } from "./show-monitoring-modal";
|
||||
import { ShowSchedulesModal } from "./show-schedules-modal";
|
||||
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
|
||||
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
|
||||
import { WelcomeSubscription } from "./welcome-stripe/welcome-subscription";
|
||||
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
|
||||
|
||||
export const ShowServers = () => {
|
||||
const router = useRouter();
|
||||
@@ -63,7 +63,7 @@ export const ShowServers = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{query?.success && isCloud && <WelcomeSubscription />}
|
||||
{query?.success && isCloud && <WelcomeSuscription />}
|
||||
<Card className="h-full p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ShowSwarmContainers } from "../../swarm/containers/show-swarm-containers";
|
||||
import SwarmMonitorCard from "../../swarm/monitoring-card";
|
||||
|
||||
interface Props {
|
||||
@@ -24,24 +21,9 @@ export const ShowSwarmOverviewModal = ({ serverId }: Props) => {
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-7xl ">
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="containers">Containers</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview">
|
||||
<div className="grid w-full gap-1">
|
||||
<SwarmMonitorCard serverId={serverId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="containers">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md p-6">
|
||||
<ShowSwarmContainers serverId={serverId} />
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className="grid w-full gap-1">
|
||||
<SwarmMonitorCard serverId={serverId} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -51,12 +51,11 @@ export const { useStepper, steps, Scoped } = defineStepper(
|
||||
{ id: "complete", title: "Complete", description: "Checkout complete" },
|
||||
);
|
||||
|
||||
export const WelcomeSubscription = () => {
|
||||
export const WelcomeSuscription = () => {
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const stepper = useStepper();
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const router = useRouter();
|
||||
const { push } = router;
|
||||
const { push } = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const confettiShown = localStorage.getItem("hasShownConfetti");
|
||||
@@ -67,22 +66,7 @@ export const WelcomeSubscription = () => {
|
||||
}, [showConfetti]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
const { success, ...rest } = router.query;
|
||||
router.replace(
|
||||
{ pathname: router.pathname, query: rest },
|
||||
undefined,
|
||||
{
|
||||
shallow: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog open={isOpen}>
|
||||
<DialogContent className="sm:max-w-7xl min-h-[75vh]">
|
||||
{showConfetti ?? "Flaso"}
|
||||
<div className="flex justify-center items-center w-full">
|
||||
@@ -34,63 +34,14 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const addInvitation = z
|
||||
.object({
|
||||
mode: z.enum(["invitation", "credentials"]),
|
||||
email: z
|
||||
.string()
|
||||
.min(1, "Email is required")
|
||||
.email({ message: "Invalid email" }),
|
||||
role: z.string().min(1, "Role is required"),
|
||||
notificationId: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
confirmPassword: z.string().optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.mode !== "credentials") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value.password) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Password is required",
|
||||
path: ["password"],
|
||||
});
|
||||
} else if (value.password.length < 8) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Password must be at least 8 characters",
|
||||
path: ["password"],
|
||||
});
|
||||
}
|
||||
|
||||
if (!value.confirmPassword) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Confirm password is required",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
} else if (value.confirmPassword.length < 8) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Password must be at least 8 characters",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
value.password &&
|
||||
value.confirmPassword &&
|
||||
value.password !== value.confirmPassword
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Passwords do not match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
}
|
||||
});
|
||||
const addInvitation = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, "Email is required")
|
||||
.email({ message: "Invalid email" }),
|
||||
role: z.string().min(1, "Role is required"),
|
||||
notificationId: z.string().optional(),
|
||||
});
|
||||
|
||||
type AddInvitation = z.infer<typeof addInvitation>;
|
||||
|
||||
@@ -103,83 +54,50 @@ export const AddInvitation = () => {
|
||||
const { mutateAsync: inviteMember, isPending: isInviting } =
|
||||
api.organization.inviteMember.useMutation();
|
||||
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
|
||||
const { mutateAsync: createUserWithCredentials, isPending: isCreating } =
|
||||
api.user.createUserWithCredentials.useMutation();
|
||||
const { data: customRoles } = api.customRole.all.useQuery();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<AddInvitation>({
|
||||
defaultValues: {
|
||||
mode: "invitation",
|
||||
email: "",
|
||||
role: "member",
|
||||
notificationId: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
resolver: zodResolver(addInvitation),
|
||||
});
|
||||
|
||||
const mode = form.watch("mode");
|
||||
|
||||
useEffect(() => {
|
||||
form.reset();
|
||||
}, [form, form.formState.isSubmitSuccessful, form.reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCloud && form.getValues("mode") === "credentials") {
|
||||
form.setValue("mode", "invitation");
|
||||
}
|
||||
}, [form, isCloud]);
|
||||
|
||||
const onSubmit = async (data: AddInvitation) => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (data.mode === "credentials") {
|
||||
await createUserWithCredentials({
|
||||
email: data.email.toLowerCase(),
|
||||
password: data.password!,
|
||||
role: data.role,
|
||||
});
|
||||
toast.success("User created with initial credentials");
|
||||
setOpen(false);
|
||||
} else {
|
||||
const result = await inviteMember({
|
||||
email: data.email.toLowerCase(),
|
||||
role: data.role,
|
||||
});
|
||||
const result = await inviteMember({
|
||||
email: data.email.toLowerCase(),
|
||||
role: data.role,
|
||||
});
|
||||
|
||||
if (!isCloud && data.notificationId) {
|
||||
await sendInvitation({
|
||||
invitationId: result!.id,
|
||||
notificationId: data.notificationId || "",
|
||||
if (!isCloud && data.notificationId) {
|
||||
await sendInvitation({
|
||||
invitationId: result!.id,
|
||||
notificationId: data.notificationId || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Invitation created and email sent");
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Invitation created and email sent");
|
||||
})
|
||||
.catch((error: any) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
} else {
|
||||
toast.success("Invitation created");
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
.catch((error: any) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
} else {
|
||||
toast.success("Invitation created");
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to create user";
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
await Promise.all([
|
||||
utils.organization.allInvitations.invalidate(),
|
||||
utils.user.all.invalidate(),
|
||||
]);
|
||||
setError(null);
|
||||
setOpen(false);
|
||||
} catch (error: any) {
|
||||
setError(error.message || "Failed to create invitation");
|
||||
}
|
||||
};
|
||||
|
||||
utils.organization.allInvitations.invalidate();
|
||||
};
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger className="" asChild>
|
||||
@@ -190,11 +108,7 @@ export const AddInvitation = () => {
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Invitation</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === "credentials"
|
||||
? "Create a user with initial credentials"
|
||||
: "Invite a new user"}
|
||||
</DialogDescription>
|
||||
<DialogDescription>Invite a new user</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error && <AlertBlock type="error">{error}</AlertBlock>}
|
||||
|
||||
@@ -204,43 +118,6 @@ export const AddInvitation = () => {
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4 "
|
||||
>
|
||||
{!isCloud && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mode"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Invite Method</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select invite method" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="invitation">
|
||||
Invitation Link
|
||||
</SelectItem>
|
||||
<SelectItem value="credentials">
|
||||
Initial Credentials
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose between invitation link flow or direct
|
||||
credentials provisioning
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
@@ -295,7 +172,7 @@ export const AddInvitation = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
{!isCloud && mode === "invitation" && (
|
||||
{!isCloud && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notificationId"
|
||||
@@ -335,57 +212,9 @@ export const AddInvitation = () => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isCloud && mode === "credentials" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter initial password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The user can sign in with this password immediately
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Confirm initial password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex w-full flex-row">
|
||||
<Button
|
||||
isLoading={isInviting || isCreating}
|
||||
isLoading={isInviting}
|
||||
form="hook-form-add-invitation"
|
||||
type="submit"
|
||||
>
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { EnterpriseFeatureLocked } from "@/components/proprietary/enterprise-feature-gate";
|
||||
import { api, type RouterOutputs } from "@/utils/api";
|
||||
|
||||
/** Shape returned by project.allForPermissions (admin only). Used for the permissions UI. */
|
||||
@@ -171,8 +170,6 @@ const addPermissions = z.object({
|
||||
accessedProjects: z.array(z.string()).optional(),
|
||||
accessedEnvironments: z.array(z.string()).optional(),
|
||||
accessedServices: z.array(z.string()).optional(),
|
||||
accessedGitProviders: z.array(z.string()).optional(),
|
||||
accessedServers: z.array(z.string()).optional(),
|
||||
canCreateProjects: z.boolean().optional().default(false),
|
||||
canCreateServices: z.boolean().optional().default(false),
|
||||
canDeleteProjects: z.boolean().optional().default(false),
|
||||
@@ -199,19 +196,6 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
|
||||
const { data: projects } = api.project.allForPermissions.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
});
|
||||
const { data: haveValidLicense } =
|
||||
api.licenseKey.haveValidLicenseKey.useQuery();
|
||||
|
||||
const { data: gitProviders } = api.gitProvider.allForPermissions.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: isOpen && !!haveValidLicense,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: servers } = api.server.allForPermissions.useQuery(undefined, {
|
||||
enabled: isOpen && !!haveValidLicense,
|
||||
});
|
||||
|
||||
const { data, refetch } = api.user.one.useQuery(
|
||||
{
|
||||
@@ -230,8 +214,6 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
|
||||
accessedProjects: [],
|
||||
accessedEnvironments: [],
|
||||
accessedServices: [],
|
||||
accessedGitProviders: [],
|
||||
accessedServers: [],
|
||||
canDeleteEnvironments: false,
|
||||
canCreateProjects: false,
|
||||
canCreateServices: false,
|
||||
@@ -253,8 +235,6 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
|
||||
accessedProjects: data.accessedProjects || [],
|
||||
accessedEnvironments: data.accessedEnvironments || [],
|
||||
accessedServices: data.accessedServices || [],
|
||||
accessedGitProviders: data.accessedGitProviders || [],
|
||||
accessedServers: data.accessedServers || [],
|
||||
canCreateProjects: data.canCreateProjects,
|
||||
canCreateServices: data.canCreateServices,
|
||||
canDeleteProjects: data.canDeleteProjects,
|
||||
@@ -282,8 +262,6 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
|
||||
accessedProjects: data.accessedProjects || [],
|
||||
accessedEnvironments: data.accessedEnvironments || [],
|
||||
accessedServices: data.accessedServices || [],
|
||||
accessedGitProviders: data.accessedGitProviders || [],
|
||||
accessedServers: data.accessedServers || [],
|
||||
canAccessToDocker: data.canAccessToDocker,
|
||||
canAccessToAPI: data.canAccessToAPI,
|
||||
canAccessToSSHKeys: data.canAccessToSSHKeys,
|
||||
@@ -892,151 +870,6 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{haveValidLicense ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="accessedGitProviders"
|
||||
render={() => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<div className="mb-4">
|
||||
<FormLabel className="text-base">Git Providers</FormLabel>
|
||||
<FormDescription>
|
||||
Select the Git Providers that the user can access
|
||||
</FormDescription>
|
||||
</div>
|
||||
{gitProviders?.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No git providers found
|
||||
</p>
|
||||
)}
|
||||
<div className="grid md:grid-cols-1 gap-2">
|
||||
{gitProviders?.map((provider) => (
|
||||
<FormField
|
||||
key={provider.gitProviderId}
|
||||
control={form.control}
|
||||
name="accessedGitProviders"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center space-x-3 space-y-0 rounded-lg border p-3">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value?.includes(
|
||||
provider.gitProviderId,
|
||||
)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
field.onChange([
|
||||
...(field.value || []),
|
||||
provider.gitProviderId,
|
||||
]);
|
||||
} else {
|
||||
field.onChange(
|
||||
field.value?.filter(
|
||||
(v) => v !== provider.gitProviderId,
|
||||
),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="text-sm cursor-pointer">
|
||||
{provider.name}
|
||||
</FormLabel>
|
||||
<span className="text-xs text-muted-foreground capitalize">
|
||||
({provider.providerType})
|
||||
</span>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="md:col-span-2">
|
||||
<EnterpriseFeatureLocked
|
||||
compact
|
||||
title="Git Provider Assignment"
|
||||
description="Assign specific Git Providers to users with an Enterprise license."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{haveValidLicense ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="accessedServers"
|
||||
render={() => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<div className="mb-4">
|
||||
<FormLabel className="text-base">Servers</FormLabel>
|
||||
<FormDescription>
|
||||
Select the Servers that the user can access
|
||||
</FormDescription>
|
||||
</div>
|
||||
{servers?.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No servers found
|
||||
</p>
|
||||
)}
|
||||
<div className="grid md:grid-cols-1 gap-2">
|
||||
{servers?.map((s) => (
|
||||
<FormField
|
||||
key={s.serverId}
|
||||
control={form.control}
|
||||
name="accessedServers"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center space-x-3 space-y-0 rounded-lg border p-3">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value?.includes(s.serverId)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
field.onChange([
|
||||
...(field.value || []),
|
||||
s.serverId,
|
||||
]);
|
||||
} else {
|
||||
field.onChange(
|
||||
field.value?.filter(
|
||||
(v) => v !== s.serverId,
|
||||
),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="text-sm cursor-pointer">
|
||||
{s.name}
|
||||
</FormLabel>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({s.ipAddress})
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground capitalize">
|
||||
{s.serverType}
|
||||
</span>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="md:col-span-2">
|
||||
<EnterpriseFeatureLocked
|
||||
compact
|
||||
title="Server Assignment"
|
||||
description="Assign specific Servers to users with an Enterprise license."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="flex w-full flex-row justify-end md:col-span-2">
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
|
||||
@@ -153,7 +153,7 @@ export const ChangeRole = ({ memberId, currentRole, userEmail }: Props) => {
|
||||
)}
|
||||
<br />
|
||||
<em className="text-muted-foreground text-xs">
|
||||
Note: Owner role is nontransferable.
|
||||
Note: Owner role is intransferible.
|
||||
</em>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
|
||||
@@ -122,7 +122,7 @@ export const ShowUsers = () => {
|
||||
// Can change role based on hierarchy:
|
||||
// - Owner: Can change anyone's role (except themselves and other owners)
|
||||
// - Admin: Can only change member/custom roles (not other admins or owners)
|
||||
// - Owner role is nontransferable
|
||||
// - Owner role is intransferible
|
||||
const canChangeRole =
|
||||
member.role !== "owner" &&
|
||||
member.user.id !== session?.user?.id &&
|
||||
|
||||
@@ -87,12 +87,7 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
|
||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
e.code === "KeyS" &&
|
||||
!isPending &&
|
||||
!canEdit
|
||||
) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending && !canEdit) {
|
||||
e.preventDefault();
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { AlertCircle, HardDrive, Network } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { ContainerInfo, ContainerStat } from "./types";
|
||||
import { formatCpu, formatIOValue, formatMemUsage } from "./utils";
|
||||
|
||||
interface ContainerRowProps {
|
||||
container: ContainerInfo;
|
||||
stat: ContainerStat | undefined;
|
||||
}
|
||||
|
||||
export const ContainerRow = ({ container, stat }: ContainerRowProps) => {
|
||||
const isRunning = container.CurrentState.startsWith("Running");
|
||||
const hasError = container.Error && container.Error.trim() !== "";
|
||||
|
||||
const stateBadge = (
|
||||
<Badge
|
||||
variant={hasError ? "destructive" : isRunning ? "default" : "destructive"}
|
||||
>
|
||||
{container.CurrentState}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium text-sm">{container.Name}</span>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[230px]">
|
||||
{container.Image}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{hasError ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center gap-1.5 cursor-help">
|
||||
{stateBadge}
|
||||
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<p className="text-xs font-medium">Error:</p>
|
||||
<p className="text-xs">{container.Error}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
stateBadge
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{stat ? (
|
||||
<span className="text-sm font-medium">{formatCpu(stat.CPUPerc)}</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">--</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{stat ? (
|
||||
<span className="text-sm font-medium">
|
||||
{formatMemUsage(stat.MemUsage)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">--</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{stat ? (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<HardDrive className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-sm">{formatIOValue(stat.BlockIO)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">--</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{stat ? (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<Network className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-sm">{formatIOValue(stat.NetIO)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">--</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
@@ -1,277 +0,0 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ExternalLink,
|
||||
Info,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ContainerInfo } from "./types";
|
||||
|
||||
export const DocLinks = () => (
|
||||
<div className="flex flex-col gap-1 pt-2 border-t mt-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Helpful resources:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<a
|
||||
href="https://docs.dokploy.com/docs/core"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary underline underline-offset-4 inline-flex items-center gap-1"
|
||||
>
|
||||
Dokploy Documentation
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
<a
|
||||
href="https://docs.docker.com/engine/swarm/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary underline underline-offset-4 inline-flex items-center gap-1"
|
||||
>
|
||||
Docker Swarm Guide
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
className="text-xs text-primary underline underline-offset-4 inline-flex items-center gap-1"
|
||||
>
|
||||
Cluster Settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface SwarmNotAvailableProps {
|
||||
errorMessage?: string;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
export const SwarmNotAvailable = ({
|
||||
errorMessage,
|
||||
onRetry,
|
||||
}: SwarmNotAvailableProps) => (
|
||||
<div className="flex flex-col gap-4 py-6 max-w-2xl mx-auto">
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Swarm Not Available</AlertTitle>
|
||||
<AlertDescription>
|
||||
Could not reach Docker Swarm.{" "}
|
||||
{errorMessage && (
|
||||
<span className="block mt-1 text-xs opacity-80">{errorMessage}</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
This feature requires Docker Swarm to be initialized and active. To get
|
||||
started:
|
||||
</p>
|
||||
<ol className="list-decimal list-inside space-y-2 ml-1">
|
||||
<li>
|
||||
Initialize Swarm on your server:{" "}
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">
|
||||
docker swarm init
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
Verify it's active:{" "}
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">
|
||||
docker info | grep Swarm
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
Check the{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
Cluster Settings
|
||||
</Link>{" "}
|
||||
page to manage your swarm nodes
|
||||
</li>
|
||||
</ol>
|
||||
<DocLinks />
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="w-fit" onClick={onRetry}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface ServicesErrorProps {
|
||||
errorMessage?: string;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
export const ServicesError = ({
|
||||
errorMessage,
|
||||
onRetry,
|
||||
}: ServicesErrorProps) => (
|
||||
<div className="flex flex-col gap-4 py-6 max-w-2xl mx-auto">
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Failed to Load Services</AlertTitle>
|
||||
<AlertDescription>
|
||||
Swarm is reachable but service listing failed.{" "}
|
||||
{errorMessage && (
|
||||
<span className="block mt-1 text-xs opacity-80">{errorMessage}</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>This could be caused by:</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-1">
|
||||
<li>Permission issues running Docker commands on the server</li>
|
||||
<li>Docker daemon not responding</li>
|
||||
<li>
|
||||
Network connectivity issues to a remote server — check{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
Cluster Settings
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="w-fit" onClick={onRetry}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface NoServicesProps {
|
||||
nodeCount: number;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export const NoServices = ({ nodeCount, onRefresh }: NoServicesProps) => (
|
||||
<div className="flex flex-col gap-4 py-6 max-w-2xl mx-auto">
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>No Swarm Services Found</AlertTitle>
|
||||
<AlertDescription>
|
||||
Docker Swarm is active with <strong>{nodeCount} node(s)</strong>, but
|
||||
there are no application services running in the swarm.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
This view shows containers deployed as <strong>Swarm services</strong>.
|
||||
Standalone or Docker Compose containers won't appear here.
|
||||
</p>
|
||||
<p>To see containers in this view, make sure your applications are:</p>
|
||||
<ol className="list-decimal list-inside space-y-2 ml-1">
|
||||
<li>
|
||||
<strong>Deployed as Swarm services</strong> — Applications in
|
||||
Dokploy deploy to Swarm by default. Docker Compose projects need to
|
||||
use{" "}
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">Stack</code>{" "}
|
||||
type (not{" "}
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">
|
||||
Docker Compose
|
||||
</code>
|
||||
) to run as Swarm services.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Using a registry</strong> (for multi-node setups) —
|
||||
Worker nodes need to pull images from a shared registry. Configure one
|
||||
in{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
Cluster Settings
|
||||
</Link>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Successfully built and deployed</strong> — Check your
|
||||
project's deployment logs for errors.
|
||||
</li>
|
||||
</ol>
|
||||
<DocLinks />
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="w-fit" onClick={onRefresh}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface NoRunningContainersProps {
|
||||
serviceCount: number;
|
||||
containers: ContainerInfo[];
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export const NoRunningContainers = ({
|
||||
serviceCount,
|
||||
containers,
|
||||
onRefresh,
|
||||
}: NoRunningContainersProps) => {
|
||||
const hasErrors = containers.some((c) => c.Error && c.Error.trim() !== "");
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-6 max-w-2xl mx-auto">
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>No Running Containers</AlertTitle>
|
||||
<AlertDescription>
|
||||
Found <strong>{serviceCount} service(s)</strong> in the swarm, but
|
||||
none have running containers.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{hasErrors && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Container Errors Detected</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-disc list-inside space-y-1 mt-1">
|
||||
{containers
|
||||
.filter((c) => c.Error && c.Error.trim() !== "")
|
||||
.slice(0, 5)
|
||||
.map((c) => (
|
||||
<li key={c.ID} className="text-xs">
|
||||
<strong>{c.Name}</strong>: {c.Error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>This can happen when:</p>
|
||||
<ul className="list-disc list-inside space-y-2 ml-1">
|
||||
<li>Services are scaled to 0 replicas</li>
|
||||
<li>
|
||||
Containers are failing to start — check deployment logs for
|
||||
errors
|
||||
</li>
|
||||
<li>
|
||||
Images can't be pulled on worker nodes — verify your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
registry configuration
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
Node constraints prevent scheduling — check placement rules in
|
||||
your app's Cluster settings
|
||||
</li>
|
||||
</ul>
|
||||
<DocLinks />
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="w-fit" onClick={onRefresh}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
import { ChevronDown, ChevronRight, Server } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ContainerRow } from "./container-row";
|
||||
import type { ContainerStat, NodeGroup } from "./types";
|
||||
|
||||
interface NodeSectionProps {
|
||||
group: NodeGroup;
|
||||
isExpanded: boolean;
|
||||
onToggleNode: (nodeName: string) => void;
|
||||
findStatsForContainer: (taskName: string) => ContainerStat | undefined;
|
||||
}
|
||||
|
||||
export const NodeSection = ({
|
||||
group,
|
||||
isExpanded,
|
||||
onToggleNode,
|
||||
findStatsForContainer,
|
||||
}: NodeSectionProps) => {
|
||||
const runningCount = group.containers.filter((c) =>
|
||||
c.CurrentState.startsWith("Running"),
|
||||
).length;
|
||||
|
||||
const nodeDown =
|
||||
group.nodeStatus &&
|
||||
(group.nodeStatus.Status !== "Ready" ||
|
||||
group.nodeStatus.Availability !== "Active");
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={isExpanded}
|
||||
onOpenChange={() => onToggleNode(group.nodeName)}
|
||||
>
|
||||
<Card className="bg-background">
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors rounded-t-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<div className="relative">
|
||||
<Server className="h-5 w-5 text-muted-foreground" />
|
||||
{nodeDown && (
|
||||
<span className="absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full bg-destructive" />
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-base">{group.nodeName}</CardTitle>
|
||||
{group.nodeStatus && (
|
||||
<Badge
|
||||
variant={
|
||||
group.nodeStatus.ManagerStatus === "Leader"
|
||||
? "default"
|
||||
: group.nodeStatus.ManagerStatus === "Reachable"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{group.nodeStatus.ManagerStatus || "Worker"}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="secondary">
|
||||
{group.containers.length} container
|
||||
{group.containers.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
{nodeDown ? (
|
||||
<Badge variant="destructive">
|
||||
{group.nodeStatus?.Status} /{" "}
|
||||
{group.nodeStatus?.Availability}
|
||||
</Badge>
|
||||
) : runningCount === group.containers.length ? (
|
||||
<Badge variant="default">All Running</Badge>
|
||||
) : (
|
||||
<Badge variant="orange">
|
||||
{runningCount}/{group.containers.length} Running
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="pt-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px]">Container</TableHead>
|
||||
<TableHead>State</TableHead>
|
||||
<TableHead className="text-right">CPU</TableHead>
|
||||
<TableHead className="text-right">Memory</TableHead>
|
||||
<TableHead className="text-right">Block I/O</TableHead>
|
||||
<TableHead className="text-right">Network I/O</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.containers.map((container) => {
|
||||
const stat = findStatsForContainer(container.Name);
|
||||
return (
|
||||
<ContainerRow
|
||||
key={container.ID}
|
||||
container={container}
|
||||
stat={stat}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
@@ -1,371 +0,0 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Container,
|
||||
Info,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardTitle } from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
NoRunningContainers,
|
||||
NoServices,
|
||||
ServicesError,
|
||||
SwarmNotAvailable,
|
||||
} from "./empty-states";
|
||||
import { NodeSection } from "./node-section";
|
||||
import { SummaryCards } from "./summary-cards";
|
||||
import type { ContainerInfo, ContainerStat, SwarmNode } from "./types";
|
||||
|
||||
interface Props {
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export const ShowSwarmContainers = ({ serverId }: Props) => {
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||
|
||||
const {
|
||||
data: nodes,
|
||||
isLoading: nodesLoading,
|
||||
isError: nodesError,
|
||||
error: nodesErrorDetail,
|
||||
refetch: refetchNodes,
|
||||
} = api.swarm.getNodes.useQuery({ serverId });
|
||||
|
||||
const {
|
||||
data: nodeApps,
|
||||
isLoading: appsLoading,
|
||||
isError: appsError,
|
||||
error: appsErrorDetail,
|
||||
refetch: refetchApps,
|
||||
} = api.swarm.getNodeApps.useQuery(
|
||||
{ serverId },
|
||||
{ enabled: !nodesError && nodes !== undefined },
|
||||
);
|
||||
|
||||
const applicationList =
|
||||
nodeApps && nodeApps.length > 0
|
||||
? nodeApps.map((app: { Name: string }) => app.Name)
|
||||
: [];
|
||||
|
||||
const {
|
||||
data: appDetails,
|
||||
isLoading: detailsLoading,
|
||||
refetch: refetchDetails,
|
||||
} = api.swarm.getAppInfos.useQuery(
|
||||
{ appName: applicationList, serverId },
|
||||
{ enabled: applicationList.length > 0 },
|
||||
);
|
||||
|
||||
const { data: stats, isLoading: statsLoading } =
|
||||
api.swarm.getContainerStats.useQuery(
|
||||
{ serverId },
|
||||
{
|
||||
refetchInterval: 5000,
|
||||
enabled: applicationList.length > 0 && !nodesError && !appsError,
|
||||
},
|
||||
);
|
||||
|
||||
const isLoading =
|
||||
nodesLoading ||
|
||||
appsLoading ||
|
||||
(applicationList.length > 0 && detailsLoading);
|
||||
|
||||
// Build container list
|
||||
const containers: ContainerInfo[] = [];
|
||||
if (nodeApps && appDetails) {
|
||||
for (const app of nodeApps) {
|
||||
const details =
|
||||
appDetails?.filter((detail: { Name: string }) =>
|
||||
detail.Name.startsWith(`${app.Name}.`),
|
||||
) || [];
|
||||
|
||||
if (details.length === 0) {
|
||||
containers.push({
|
||||
...app,
|
||||
CurrentState: "N/A",
|
||||
DesiredState: "N/A",
|
||||
Error: "",
|
||||
Node: "N/A",
|
||||
ID: app.ID,
|
||||
});
|
||||
} else {
|
||||
for (const detail of details) {
|
||||
containers.push({
|
||||
Name: detail.Name,
|
||||
Image: detail.Image || app.Image,
|
||||
CurrentState: detail.CurrentState,
|
||||
DesiredState: detail.DesiredState,
|
||||
Error: detail.Error,
|
||||
Node: detail.Node,
|
||||
Ports: detail.Ports || app.Ports,
|
||||
ID: detail.ID,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const runningContainers = containers.filter(
|
||||
(c) =>
|
||||
c.Node !== "N/A" &&
|
||||
(c.DesiredState === "Running" || c.CurrentState.startsWith("Running")),
|
||||
);
|
||||
|
||||
const unscheduledServices = containers.filter((c) => c.Node === "N/A");
|
||||
|
||||
const downNodes = (nodes ?? []).filter(
|
||||
(n: SwarmNode) => n.Status !== "Ready" || n.Availability !== "Active",
|
||||
);
|
||||
|
||||
const isMultiNode = (nodes?.length ?? 0) > 1;
|
||||
|
||||
const nodeStatusMap = new Map<string, SwarmNode>();
|
||||
if (nodes) {
|
||||
for (const node of nodes) {
|
||||
nodeStatusMap.set(node.Hostname, node);
|
||||
}
|
||||
}
|
||||
|
||||
const statsMap = new Map<string, ContainerStat>();
|
||||
if (stats) {
|
||||
for (const stat of stats) {
|
||||
statsMap.set(stat.Name, stat);
|
||||
}
|
||||
}
|
||||
|
||||
const findStatsForContainer = (
|
||||
taskName: string,
|
||||
): ContainerStat | undefined => {
|
||||
for (const [containerName, stat] of statsMap) {
|
||||
if (containerName.startsWith(`${taskName}.`)) {
|
||||
return stat;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (runningContainers.length > 0 && expandedNodes.size === 0) {
|
||||
const nodeNames = new Set<string>();
|
||||
for (const c of runningContainers) {
|
||||
if (c.Node) {
|
||||
nodeNames.add(c.Node);
|
||||
}
|
||||
}
|
||||
setExpandedNodes(nodeNames);
|
||||
}
|
||||
}, [runningContainers.length]);
|
||||
|
||||
const toggleNode = (nodeName: string) => {
|
||||
setExpandedNodes((prev: Set<string>) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(nodeName)) {
|
||||
next.delete(nodeName);
|
||||
} else {
|
||||
next.add(nodeName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetchApps();
|
||||
refetchDetails();
|
||||
};
|
||||
|
||||
// Build node groups
|
||||
const nodeMap = new Map<string, ContainerInfo[]>();
|
||||
for (const c of runningContainers) {
|
||||
const nodeName = c.Node || "Unknown";
|
||||
if (!nodeMap.has(nodeName)) {
|
||||
nodeMap.set(nodeName, []);
|
||||
}
|
||||
nodeMap.get(nodeName)!.push(c);
|
||||
}
|
||||
|
||||
const nodeGroups = [];
|
||||
for (const [nodeName, nodeContainers] of nodeMap) {
|
||||
nodeGroups.push({
|
||||
nodeName,
|
||||
containers: nodeContainers,
|
||||
nodeStatus: nodeStatusMap.get(nodeName),
|
||||
});
|
||||
}
|
||||
nodeGroups.sort((a, b) => a.nodeName.localeCompare(b.nodeName));
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[40vh]">
|
||||
<span>Loading containers...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (nodesError) {
|
||||
return (
|
||||
<SwarmNotAvailable
|
||||
errorMessage={nodesErrorDetail?.message}
|
||||
onRetry={() => refetchNodes()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!nodesError && nodes === undefined) {
|
||||
return (
|
||||
<SwarmNotAvailable
|
||||
errorMessage="Docker Swarm may not be initialized — docker node ls returned no data."
|
||||
onRetry={() => refetchNodes()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isRealAppsError =
|
||||
appsError && !appsErrorDetail?.message?.includes("data is undefined");
|
||||
if (isRealAppsError) {
|
||||
return (
|
||||
<ServicesError
|
||||
errorMessage={appsErrorDetail?.message}
|
||||
onRetry={() => refetchApps()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!nodeApps || nodeApps.length === 0) {
|
||||
return (
|
||||
<NoServices
|
||||
nodeCount={nodes?.length ?? 0}
|
||||
onRefresh={() => refetchApps()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (runningContainers.length === 0) {
|
||||
return (
|
||||
<NoRunningContainers
|
||||
serviceCount={nodeApps.length}
|
||||
containers={containers}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex items-center flex-wrap gap-4 justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Container className="size-6 text-muted-foreground self-center" />
|
||||
Container Breakdown by Node
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing containers across {nodes?.length ?? 0} swarm node(s)
|
||||
{statsLoading ? "" : " (metrics refresh every 5s)"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<SummaryCards
|
||||
nodeCount={nodes?.length ?? 0}
|
||||
downNodeCount={downNodes.length}
|
||||
serviceCount={nodeApps?.length ?? 0}
|
||||
unscheduledCount={unscheduledServices.length}
|
||||
runningContainerCount={runningContainers.length}
|
||||
/>
|
||||
|
||||
{downNodes.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>{downNodes.length} Node(s) Unavailable</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p className="mb-2">
|
||||
The following nodes are not ready or have been drained. Containers
|
||||
scheduled on these nodes may not be running.
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-xs">
|
||||
{downNodes.map((node: SwarmNode) => (
|
||||
<li key={node.ID}>
|
||||
<strong>{node.Hostname}</strong> — Status: {node.Status}
|
||||
, Availability: {node.Availability}
|
||||
{node.ManagerStatus && ` (${node.ManagerStatus})`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 text-xs">
|
||||
Manage nodes in{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
Cluster Settings
|
||||
</Link>
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isMultiNode && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Multi-Node Metrics Note</AlertTitle>
|
||||
<AlertDescription>
|
||||
CPU, memory, and I/O metrics are collected from the manager node via{" "}
|
||||
<code className="bg-muted px-1 py-0.5 rounded text-xs">
|
||||
docker stats
|
||||
</code>
|
||||
. Containers running on worker nodes will show “--” for
|
||||
metrics.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{nodeGroups.map((group) => (
|
||||
<NodeSection
|
||||
key={group.nodeName}
|
||||
group={group}
|
||||
isExpanded={expandedNodes.has(group.nodeName)}
|
||||
onToggleNode={toggleNode}
|
||||
findStatsForContainer={findStatsForContainer}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{unscheduledServices.length > 0 && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{unscheduledServices.length} Service(s) With No Running Tasks
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p className="mb-2">
|
||||
These services exist in the swarm but have no running containers.
|
||||
They may be scaled to 0 replicas or failing to start.
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-xs">
|
||||
{unscheduledServices.map((svc) => (
|
||||
<li key={svc.ID}>
|
||||
<strong>{svc.Name}</strong>
|
||||
{svc.Error && svc.Error.trim() !== "" && (
|
||||
<span className="text-destructive ml-1">
|
||||
— {svc.Error}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Container, Cpu, Server } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface SummaryCardsProps {
|
||||
nodeCount: number;
|
||||
downNodeCount: number;
|
||||
serviceCount: number;
|
||||
unscheduledCount: number;
|
||||
runningContainerCount: number;
|
||||
}
|
||||
|
||||
export const SummaryCards = ({
|
||||
nodeCount,
|
||||
downNodeCount,
|
||||
serviceCount,
|
||||
unscheduledCount,
|
||||
runningContainerCount,
|
||||
}: SummaryCardsProps) => (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Swarm Nodes</CardTitle>
|
||||
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
|
||||
<Server className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{nodeCount}</div>
|
||||
{downNodeCount > 0 && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{downNodeCount} node(s) down or drained
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Services</CardTitle>
|
||||
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
|
||||
<Cpu className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{serviceCount}</div>
|
||||
{unscheduledCount > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{unscheduledCount} with no running tasks
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Running Containers
|
||||
</CardTitle>
|
||||
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
|
||||
<Container className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{runningContainerCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
@@ -1,35 +0,0 @@
|
||||
export interface ContainerStat {
|
||||
BlockIO: string;
|
||||
CPUPerc: string;
|
||||
Container: string;
|
||||
ID: string;
|
||||
MemPerc: string;
|
||||
MemUsage: string;
|
||||
Name: string;
|
||||
NetIO: string;
|
||||
}
|
||||
|
||||
export interface ContainerInfo {
|
||||
Name: string;
|
||||
Image: string;
|
||||
Node: string;
|
||||
CurrentState: string;
|
||||
DesiredState: string;
|
||||
Ports: string;
|
||||
Error: string;
|
||||
ID: string;
|
||||
}
|
||||
|
||||
export interface SwarmNode {
|
||||
ID: string;
|
||||
Hostname: string;
|
||||
Status: string;
|
||||
Availability: string;
|
||||
ManagerStatus: string;
|
||||
}
|
||||
|
||||
export interface NodeGroup {
|
||||
nodeName: string;
|
||||
containers: ContainerInfo[];
|
||||
nodeStatus?: SwarmNode;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/** Round a value+unit string like "2.711MiB" → "2.7 MiB" */
|
||||
export const formatSizeValue = (raw: string): string => {
|
||||
const match = raw.match(/^([\d.]+)\s*([A-Za-z]+)$/);
|
||||
if (!match?.[1] || !match[2]) return raw;
|
||||
const num = Number.parseFloat(match[1]);
|
||||
const unit = match[2];
|
||||
if (Number.isNaN(num)) return raw;
|
||||
const rounded = num >= 1 ? num.toFixed(1) : num.toFixed(2);
|
||||
return `${rounded} ${unit}`;
|
||||
};
|
||||
|
||||
/** Format "2.711MiB / 7.609GiB" → "2.7 MiB / 7.6 GiB" */
|
||||
export const formatMemUsage = (raw: string): string => {
|
||||
const [left, right] = raw.split("/").map((s) => s.trim());
|
||||
if (!left || !right) return raw;
|
||||
return `${formatSizeValue(left)} / ${formatSizeValue(right)}`;
|
||||
};
|
||||
|
||||
/** Format "978B / 252B" → "978 B / 252 B" */
|
||||
export const formatIOValue = (raw: string): string => {
|
||||
const [left, right] = raw.split("/").map((s) => s.trim());
|
||||
if (!left || !right) return raw;
|
||||
return `${formatSizeValue(left)} / ${formatSizeValue(right)}`;
|
||||
};
|
||||
|
||||
/** Format "0.00%" → "0.0%", "12.345%" → "12.3%" */
|
||||
export const formatCpu = (raw: string): string => {
|
||||
const num = Number.parseFloat(raw.replace("%", ""));
|
||||
if (Number.isNaN(num)) return raw;
|
||||
return `${num.toFixed(1)}%`;
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import { api } from "@/utils/api";
|
||||
import { ChatPanel } from "../dashboard/ai-chat/chat-panel";
|
||||
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
|
||||
import { HubSpotWidget } from "../shared/HubSpotWidget";
|
||||
import Page from "./side";
|
||||
@@ -24,7 +23,6 @@ export const DashboardLayout = ({ children }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<Page>{children}</Page>
|
||||
<ChatPanel />
|
||||
{isChatEnabled && (
|
||||
<>
|
||||
<HubSpotWidget />
|
||||
|
||||
@@ -9,7 +9,7 @@ export const FocusShortcutInput = (props: Props) => {
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const isMod = e.metaKey || e.ctrlKey;
|
||||
if (!isMod || e.code !== "KeyK") return;
|
||||
if (!isMod || e.key.toLowerCase() !== "k") return;
|
||||
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target) {
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBox } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
const DATABASE_PASSWORD_REGEX = /^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/;
|
||||
|
||||
const updatePasswordSchema = z
|
||||
.object({
|
||||
password: z
|
||||
.string()
|
||||
.min(1, "Password is required")
|
||||
.regex(DATABASE_PASSWORD_REGEX, {
|
||||
message:
|
||||
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters",
|
||||
}),
|
||||
confirmPassword: z.string().min(1, "Please confirm the password"),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords do not match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
type UpdatePassword = z.infer<typeof updatePasswordSchema>;
|
||||
|
||||
interface Props {
|
||||
label?: string;
|
||||
onUpdatePassword: (newPassword: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const UpdateDatabasePassword = ({
|
||||
label = "Password",
|
||||
onUpdatePassword,
|
||||
}: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
const form = useForm<UpdatePassword>({
|
||||
defaultValues: { password: "", confirmPassword: "" },
|
||||
resolver: zodResolver(updatePasswordSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: UpdatePassword) => {
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onUpdatePassword(formData.password);
|
||||
form.reset();
|
||||
setIsOpen(false);
|
||||
} catch (e) {
|
||||
const raw = e instanceof Error ? e.message : "Error updating password";
|
||||
if (/No running container found/i.test(raw)) {
|
||||
setError(
|
||||
"The database container is not running. Please start the service before changing the password.",
|
||||
);
|
||||
} else {
|
||||
setError(raw);
|
||||
}
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<PenBox className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update {label}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter the new {label.toLowerCase()} for the database
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error && <AlertBlock type="error">{error}</AlertBlock>}
|
||||
<AlertBlock type="warning" className="my-4">
|
||||
This will change the {label.toLowerCase()} both in the running
|
||||
database container and in Dokploy. The container must be running for
|
||||
this operation to succeed.
|
||||
</AlertBlock>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>New {label}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={`Enter new ${label.toLowerCase()}`}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm {label}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={`Confirm new ${label.toLowerCase()}`}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button isLoading={isPending} type="submit">
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -101,7 +101,7 @@ const BreadcrumbEllipsis = ({
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
BreadcrumbEllipsis.displayName = "BreadcrumbEllipsis";
|
||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
|
||||
@@ -56,9 +56,9 @@ export const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
|
||||
onDrop={handleDrop}
|
||||
onClick={handleButtonClick}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground">
|
||||
<FolderIcon className="size-6 text-muted-foreground" />
|
||||
<span className="font-medium text-xl text-center">
|
||||
<div className="flex items-center justify-center text-muted-foreground">
|
||||
<span className="font-medium text-xl flex items-center gap-2">
|
||||
<FolderIcon className="size-6 text-muted-foreground" />
|
||||
{dropMessage}
|
||||
</span>
|
||||
<Input
|
||||
|
||||
@@ -19,7 +19,7 @@ interface TreeDataItem {
|
||||
|
||||
type TreeProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
data: TreeDataItem[] | TreeDataItem;
|
||||
initialSelectedItemId?: string;
|
||||
initialSlelectedItemId?: string;
|
||||
onSelectChange?: (item: TreeDataItem | undefined) => void;
|
||||
expandAll?: boolean;
|
||||
folderIcon?: LucideIcon;
|
||||
@@ -30,7 +30,7 @@ const Tree = React.forwardRef<HTMLDivElement, TreeProps>(
|
||||
(
|
||||
{
|
||||
data,
|
||||
initialSelectedItemId,
|
||||
initialSlelectedItemId,
|
||||
onSelectChange,
|
||||
expandAll,
|
||||
folderIcon,
|
||||
@@ -42,7 +42,7 @@ const Tree = React.forwardRef<HTMLDivElement, TreeProps>(
|
||||
) => {
|
||||
const [selectedItemId, setSelectedItemId] = React.useState<
|
||||
string | undefined
|
||||
>(initialSelectedItemId);
|
||||
>(initialSlelectedItemId);
|
||||
|
||||
const handleSelectChange = React.useCallback(
|
||||
(item: TreeDataItem | undefined) => {
|
||||
@@ -55,7 +55,7 @@ const Tree = React.forwardRef<HTMLDivElement, TreeProps>(
|
||||
);
|
||||
|
||||
const expandedItemIds = React.useMemo(() => {
|
||||
if (!initialSelectedItemId) {
|
||||
if (!initialSlelectedItemId) {
|
||||
return [] as string[];
|
||||
}
|
||||
|
||||
@@ -81,9 +81,9 @@ const Tree = React.forwardRef<HTMLDivElement, TreeProps>(
|
||||
}
|
||||
}
|
||||
|
||||
walkTreeItems(data, initialSelectedItemId);
|
||||
walkTreeItems(data, initialSlelectedItemId);
|
||||
return ids;
|
||||
}, [data, initialSelectedItemId]);
|
||||
}, [data, initialSlelectedItemId]);
|
||||
|
||||
const { ref: refRoot } = useResizeObserver();
|
||||
|
||||
|
||||
@@ -1,87 +1,70 @@
|
||||
import { OTPInput, OTPInputContext } from "input-otp";
|
||||
import { Dot } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const InputOTP = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange"> & {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
maxLength: number;
|
||||
}
|
||||
>(({ className, value, onChange, maxLength, ...props }, ref) => {
|
||||
const [focusedIndex, setFocusedIndex] = React.useState<number | null>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const previousValueRef = React.useRef<string>(value);
|
||||
React.ElementRef<typeof OTPInput>,
|
||||
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||
>(({ className, containerClassName, ...props }, ref) => (
|
||||
<OTPInput
|
||||
ref={ref}
|
||||
containerClassName={cn(
|
||||
"flex items-center gap-2 has-[:disabled]:opacity-50",
|
||||
containerClassName,
|
||||
)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
InputOTP.displayName = "InputOTP";
|
||||
|
||||
React.useImperativeHandle(ref, () => inputRef.current!);
|
||||
const InputOTPGroup = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center", className)} {...props} />
|
||||
));
|
||||
InputOTPGroup.displayName = "InputOTPGroup";
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value !== previousValueRef.current) {
|
||||
const newLength = value.length;
|
||||
setFocusedIndex(newLength);
|
||||
previousValueRef.current = value;
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value.replace(/\D/g, "").slice(0, maxLength);
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
const handleBoxClick = (index: number) => {
|
||||
inputRef.current?.focus();
|
||||
setFocusedIndex(index);
|
||||
};
|
||||
|
||||
const slots = Array.from({ length: maxLength }, (_, i) => {
|
||||
const char = value[i] || "";
|
||||
const isActive =
|
||||
focusedIndex === i || (focusedIndex === null && i === value.length);
|
||||
const isFilled = !!char;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => handleBoxClick(i)}
|
||||
className={cn(
|
||||
"relative flex h-11 w-11 items-center justify-center rounded-lg border-2 border-input bg-background text-base font-semibold transition-all cursor-text hover:border-ring/50",
|
||||
isActive && "border-ring ring-2 ring-ring/20 ring-offset-1",
|
||||
isFilled && "border-primary/50 bg-primary/5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="text-foreground">{char}</span>
|
||||
{isActive && !char && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-5 w-0.5 animate-caret-blink bg-primary duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
const InputOTPSlot = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div"> & { index: number }
|
||||
>(({ index, className, ...props }, ref) => {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
// @ts-ignore
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onFocus={() => setFocusedIndex(value.length)}
|
||||
onBlur={() => setFocusedIndex(null)}
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={maxLength}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-default"
|
||||
style={{ caretColor: "transparent" }}
|
||||
{...props}
|
||||
/>
|
||||
<div className="flex items-center gap-2">{slots}</div>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
|
||||
isActive && "z-10 ring-2 ring-ring ring-offset-background",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
InputOTP.displayName = "InputOTP";
|
||||
InputOTPSlot.displayName = "InputOTPSlot";
|
||||
|
||||
export { InputOTP };
|
||||
const InputOTPSeparator = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>(({ ...props }, ref) => (
|
||||
<div ref={ref} {...props}>
|
||||
<Dot />
|
||||
</div>
|
||||
));
|
||||
InputOTPSeparator.displayName = "InputOTPSeparator";
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
|
||||
@@ -22,7 +22,7 @@ const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = "16rem";
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "KeyB";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
type SidebarContext = {
|
||||
state: "expanded" | "collapsed";
|
||||
@@ -99,7 +99,7 @@ const SidebarProvider = React.forwardRef<
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.code === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user