Merge pull request #4155 from Dokploy/canary

🚀 Release v0.29.0
This commit is contained in:
Mauricio Siu
2026-04-17 14:10:37 -06:00
committed by GitHub
348 changed files with 183187 additions and 24097 deletions
-1
View File
@@ -16,7 +16,6 @@ jobs:
steps:
- uses: peakoss/anti-slop@v0
with:
max-failures: 4
blocked-commit-authors: "claude,copilot"
require-description: true
min-account-age: 5
+42
View File
@@ -68,3 +68,45 @@ jobs:
echo "✅ OpenAPI synced to website successfully"
- name: Sync to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git mcp-repo
cd mcp-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to MCP repository successfully"
- name: Sync to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git cli-repo
cd cli-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to CLI repository successfully"
+79
View File
@@ -0,0 +1,79 @@
name: Sync version to MCP and CLI repos
on:
release:
types: [published]
jobs:
sync-version:
name: Sync version to external repos
runs-on: ubuntu-latest
steps:
- name: Checkout Dokploy repository
uses: actions/checkout@v4
- name: Get version
id: get_version
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Sync version to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git mcp-repo
cd mcp-repo
# Bump version
jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
# Regenerate tools from latest OpenAPI spec
npm install -g pnpm
pnpm install
pnpm run fetch-openapi
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ steps.get_version.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Release: ${{ github.event.release.html_url }}" \
--allow-empty
git push
- name: Sync version to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git cli-repo
cd cli-repo
# Bump version
if [ -f package.json ]; then
jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
fi
# Copy latest openapi spec and regenerate commands
cp ../openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ steps.get_version.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Release: ${{ github.event.release.html_url }}" \
--allow-empty
git push
echo "CLI repo synced to version ${{ steps.get_version.outputs.version }}"
+8 -1
View File
@@ -99,7 +99,14 @@ pnpm run dokploy:build
## Docker
To build the docker image
To build the docker image first run commands to copy .env files
```bash
cp apps/dokploy/.env.production.example .env.production
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
```
then run build command
```bash
pnpm run docker:build
+2 -2
View File
@@ -19,7 +19,7 @@ Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies th
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, and Redis.
- **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.
- **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.
@@ -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 | sh
curl -sSL https://dokploy.com/install.sh | bash
```
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
@@ -32,6 +32,8 @@ describe("Host rule format regression tests", () => {
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
customEntrypoint: null,
middlewares: null,
};
describe("Host rule format validation", () => {
@@ -7,6 +7,7 @@ describe("createDomainLabels", () => {
const baseDomain: Domain = {
host: "example.com",
port: 8080,
customEntrypoint: null,
https: false,
uniqueConfigKey: 1,
customCertResolver: null,
@@ -21,6 +22,7 @@ describe("createDomainLabels", () => {
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
middlewares: null,
};
it("should create basic labels for web entrypoint", async () => {
@@ -171,12 +173,12 @@ describe("createDomainLabels", () => {
"websecure",
);
// Web entrypoint should have both middlewares with redirect first
// Web entrypoint with HTTPS should only have redirect
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,addprefix-test-app-1",
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
// Websecure should only have the addprefix middleware
// Websecure should have the addprefix middleware
expect(websecureLabels).toContain(
"traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1",
);
@@ -208,9 +210,9 @@ describe("createDomainLabels", () => {
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
// Should have middlewares in correct order: redirect, stripprefix, addprefix
// Web router with HTTPS should only have redirect
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,stripprefix-test-app-1,addprefix-test-app-1",
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
});
@@ -240,4 +242,259 @@ describe("createDomainLabels", () => {
"traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
);
});
it("should add single custom middleware to router", async () => {
const customMiddlewareDomain = {
...baseDomain,
middlewares: ["auth@file"],
};
const labels = await createDomainLabels(
appName,
customMiddlewareDomain,
"web",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=auth@file",
);
});
it("should add multiple custom middlewares to router", async () => {
const customMiddlewareDomain = {
...baseDomain,
middlewares: ["auth@file", "rate-limit@file"],
};
const labels = await createDomainLabels(
appName,
customMiddlewareDomain,
"web",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=auth@file,rate-limit@file",
);
});
it("should only have redirect on web router when HTTPS is enabled with custom middlewares", async () => {
const combinedDomain = {
...baseDomain,
https: true,
middlewares: ["auth@file"],
};
const labels = await createDomainLabels(appName, combinedDomain, "web");
// Web router with HTTPS should only redirect, custom middlewares go on websecure
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
expect(labels).not.toContain("auth@file");
});
it("should combine custom middlewares with stripPath middleware (no HTTPS)", async () => {
const combinedDomain = {
...baseDomain,
path: "/api",
stripPath: true,
middlewares: ["auth@file"],
};
const labels = await createDomainLabels(appName, combinedDomain, "web");
// stripprefix should come before custom middleware
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=stripprefix-test-app-1,auth@file",
);
});
it("should only have redirect on web router even with all built-in middlewares and custom middlewares", async () => {
const fullDomain = {
...baseDomain,
https: true,
path: "/api",
stripPath: true,
internalPath: "/hello",
middlewares: ["auth@file", "rate-limit@file"],
};
const webLabels = await createDomainLabels(appName, fullDomain, "web");
// Web router with HTTPS should only redirect
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
// Middleware definitions should still be present (Traefik needs them registered)
expect(webLabels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(webLabels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
// But they should NOT be attached to the router
expect(webLabels).not.toContain("stripprefix-test-app-1,");
expect(webLabels).not.toContain("auth@file");
expect(webLabels).not.toContain("rate-limit@file");
});
it("should include custom middlewares on websecure entrypoint", async () => {
const customMiddlewareDomain = {
...baseDomain,
https: true,
middlewares: ["auth@file"],
};
const websecureLabels = await createDomainLabels(
appName,
customMiddlewareDomain,
"websecure",
);
// Websecure should have custom middleware but not redirect-to-https
expect(websecureLabels).toContain(
"traefik.http.routers.test-app-1-websecure.middlewares=auth@file",
);
expect(websecureLabels).not.toContain("redirect-to-https");
});
it("should NOT include custom middlewares on web router when HTTPS is enabled (only redirect)", async () => {
const domain = {
...baseDomain,
https: true,
middlewares: ["rate-limit@file", "auth@file"],
};
const webLabels = await createDomainLabels(appName, domain, "web");
// Web router with HTTPS should ONLY have redirect, not custom middlewares
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
expect(webLabels).not.toContain("rate-limit@file");
expect(webLabels).not.toContain("auth@file");
});
it("should create basic labels for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{ ...baseDomain, customEntrypoint: "custom" },
"custom",
);
expect(labels).toEqual([
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
]);
});
it("should create https labels for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
https: true,
customEntrypoint: "custom",
certificateType: "letsencrypt",
},
"custom",
);
expect(labels).toEqual([
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
"traefik.http.routers.test-app-1-custom.tls.certresolver=letsencrypt",
]);
});
it("should add stripPath middleware for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1",
);
});
it("should add internalPath middleware for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
internalPath: "/hello",
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=addprefix-test-app-1",
);
});
it("should add path prefix in rule for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
},
"custom",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`) && PathPrefix(`/api`)",
);
});
it("should combine all middlewares for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
internalPath: "/hello",
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(labels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
);
});
it("should not add redirect-to-https for custom entrypoint even with https", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
https: true,
certificateType: "letsencrypt",
},
"custom",
);
const middlewareLabel = labels.find((l) => l.includes(".middlewares="));
// Should not contain redirect-to-https since there's only one router
expect(middlewareLabel).toBeUndefined();
});
});
@@ -292,7 +292,7 @@ networks:
dokploy-network:
`;
test("It shoudn't add suffix to dokploy-network", () => {
test("It shouldn't add suffix to dokploy-network", () => {
const composeData = parse(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -195,7 +195,7 @@ services:
- dokploy-network
`;
test("It shoudn't add suffix to dokploy-network in services", () => {
test("It shouldn't add suffix to dokploy-network in services", () => {
const composeData = parse(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -241,10 +241,10 @@ services:
dokploy-network:
aliases:
- apid
`;
test("It shoudn't add suffix to dokploy-network in services multiples cases", () => {
test("It shouldn't add suffix to dokploy-network in services multiples cases", () => {
const composeData = parse(composeFile8) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -415,5 +415,24 @@ 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",
);
});
});
});
+1
View File
@@ -120,6 +120,7 @@ const baseApp: ApplicationNested = {
environmentId: "",
enabled: null,
env: null,
icon: null,
healthCheckSwarm: null,
labelsSwarm: null,
memoryLimit: null,
+10 -10
View File
@@ -1,4 +1,4 @@
import { getEnviromentVariablesObject } from "@dokploy/server/index";
import { getEnvironmentVariablesObject } from "@dokploy/server/index";
import { describe, expect, it } from "vitest";
const projectEnv = `
@@ -15,7 +15,7 @@ DATABASE_NAME=dev_database
SECRET_KEY=env-secret-123
`;
describe("getEnviromentVariablesObject with environment variables (Stack compose)", () => {
describe("getEnvironmentVariablesObject with environment variables (Stack compose)", () => {
it("resolves environment variables correctly for Stack compose", () => {
const serviceEnv = `
FOO=\${{environment.NODE_ENV}}
@@ -23,7 +23,7 @@ BAR=\${{environment.API_URL}}
BAZ=test
`;
const result = getEnviromentVariablesObject(
const result = getEnvironmentVariablesObject(
serviceEnv,
projectEnv,
environmentEnv,
@@ -45,7 +45,7 @@ DATABASE_URL=\${{project.DATABASE_URL}}
SERVICE_PORT=4000
`;
const result = getEnviromentVariablesObject(
const result = getEnvironmentVariablesObject(
serviceEnv,
projectEnv,
environmentEnv,
@@ -72,7 +72,7 @@ PASSWORD=secret123
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
`;
const result = getEnviromentVariablesObject(serviceEnv, "", multiRefEnv);
const result = getEnvironmentVariablesObject(serviceEnv, "", multiRefEnv);
expect(result).toEqual({
DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
@@ -85,7 +85,7 @@ UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}}
`;
expect(() =>
getEnviromentVariablesObject(serviceWithUndefined, "", environmentEnv),
getEnvironmentVariablesObject(serviceWithUndefined, "", environmentEnv),
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
});
@@ -95,7 +95,7 @@ NODE_ENV=production
API_URL=\${{environment.API_URL}}
`;
const result = getEnviromentVariablesObject(
const result = getEnvironmentVariablesObject(
serviceOverrideEnv,
"",
environmentEnv,
@@ -115,7 +115,7 @@ SERVICE_NAME=my-service
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
`;
const result = getEnviromentVariablesObject(
const result = getEnvironmentVariablesObject(
complexServiceEnv,
projectEnv,
environmentEnv,
@@ -150,7 +150,7 @@ ENV_VAR=\${{environment.API_URL}}
DB_NAME=\${{environment.DATABASE_NAME}}
`;
const result = getEnviromentVariablesObject(
const result = getEnvironmentVariablesObject(
serviceWithConflicts,
conflictingProjectEnv,
conflictingEnvironmentEnv,
@@ -170,7 +170,7 @@ SERVICE_VAR=test
PROJECT_VAR=\${{project.ENVIRONMENT}}
`;
const result = getEnviromentVariablesObject(
const result = getEnvironmentVariablesObject(
serviceWithEmpty,
projectEnv,
"",
@@ -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",
@@ -35,6 +35,7 @@ const ENTERPRISE_RESOURCES = [
"domain",
"destination",
"notification",
"tag",
"logs",
"monitoring",
"auditLog",
@@ -57,7 +57,7 @@ const createApplication = (
env: null,
},
replicas: 1,
stopGracePeriodSwarm: 0n,
stopGracePeriodSwarm: 0,
ulimitsSwarm: null,
serverId: "server-id",
...overrides,
@@ -76,8 +76,8 @@ describe("mechanizeDockerContainer", () => {
});
});
it("converts bigint stopGracePeriodSwarm to a number and keeps zero values", async () => {
const application = createApplication({ stopGracePeriodSwarm: 0n });
it("passes stopGracePeriodSwarm as a number and keeps zero values", async () => {
const application = createApplication({ stopGracePeriodSwarm: 0 });
await mechanizeDockerContainer(application);
@@ -95,6 +95,7 @@ const baseApp: ApplicationNested = {
dropBuildPath: null,
enabled: null,
env: null,
icon: null,
healthCheckSwarm: null,
labelsSwarm: null,
memoryLimit: null,
@@ -137,6 +138,7 @@ const baseDomain: Domain = {
https: false,
path: null,
port: null,
customEntrypoint: null,
serviceName: "",
composeId: "",
customCertResolver: null,
@@ -145,6 +147,7 @@ const baseDomain: Domain = {
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
middlewares: null,
};
const baseRedirect: Redirect = {
@@ -264,6 +267,80 @@ test("Websecure entrypoint on https domain with redirect", async () => {
expect(router.middlewares).toContain("redirect-test-1");
});
/** Custom Middlewares */
test("Web entrypoint with single custom middleware", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, middlewares: ["auth@file"] },
"web",
);
expect(router.middlewares).toContain("auth@file");
});
test("Web entrypoint with multiple custom middlewares", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, middlewares: ["auth@file", "rate-limit@file"] },
"web",
);
expect(router.middlewares).toContain("auth@file");
expect(router.middlewares).toContain("rate-limit@file");
});
test("Web entrypoint on https domain with custom middleware", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
"web",
);
// Should only have HTTPS redirect - custom middleware applies on websecure
expect(router.middlewares).toContain("redirect-to-https");
expect(router.middlewares).not.toContain("auth@file");
});
test("Websecure entrypoint with custom middleware", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
"websecure",
);
// Should have custom middleware but not HTTPS redirect
expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.middlewares).toContain("auth@file");
});
test("Web entrypoint with redirect and custom middleware", async () => {
const router = await createRouterConfig(
{
...baseApp,
appName: "test",
redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }],
},
{ ...baseDomain, middlewares: ["auth@file"] },
"web",
);
// Should have both redirect middleware and custom middleware
expect(router.middlewares).toContain("redirect-test-1");
expect(router.middlewares).toContain("auth@file");
});
test("Web entrypoint with empty middlewares array", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: false, middlewares: [] },
"web",
);
// Should behave same as no middlewares - no redirect for http
expect(router.middlewares).not.toContain("redirect-to-https");
});
/** Certificates */
test("CertificateType on websecure entrypoint", async () => {
@@ -276,6 +353,130 @@ 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 () => {
@@ -112,14 +112,21 @@ const menuItems: MenuItem[] = [
const hasStopGracePeriodSwarm = (
value: unknown,
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
): value is { stopGracePeriodSwarm: number | string | null } =>
typeof value === "object" &&
value !== null &&
"stopGracePeriodSwarm" in value;
interface Props {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
type:
| "application"
| "libsql"
| "mariadb"
| "mongo"
| "mysql"
| "postgres"
| "redis";
}
export const AddSwarmSettings = ({ id, type }: Props) => {
@@ -37,27 +37,27 @@ import { AddSwarmSettings } from "./modify-swarm-settings";
interface Props {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
type: "application" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis";
}
const AddRedirectchema = z.object({
const AddRedirectSchema = z.object({
replicas: z.number().min(1, "Replicas must be at least 1"),
registryId: z.string().optional(),
});
type AddCommand = z.infer<typeof AddRedirectchema>;
type AddCommand = z.infer<typeof AddRedirectSchema>;
export const ShowClusterSettings = ({ id, type }: Props) => {
const queryMap = {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -65,12 +65,13 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
const { data: registries } = api.registry.all.useQuery();
const mutationMap = {
application: () => api.application.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync, isPending } = mutationMap[type]
@@ -86,7 +87,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
: {}),
replicas: data?.replicas || 1,
},
resolver: zodResolver(AddRedirectchema),
resolver: zodResolver(AddRedirectSchema),
});
useEffect(() => {
@@ -105,11 +106,11 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
const onSubmit = async (data: AddCommand) => {
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
mysqlId: id || "",
postgresId: id || "",
redisId: id || "",
...(type === "application"
? {
registryId:
@@ -28,7 +28,14 @@ export const endpointSpecFormSchema = z.object({
interface EndpointSpecFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
}
export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
@@ -44,6 +51,7 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -56,6 +64,7 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
@@ -94,6 +103,7 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
endpointSpecSwarm: hasAnyValue ? formData : null,
});
@@ -26,7 +26,14 @@ export const healthCheckFormSchema = z.object({
interface HealthCheckFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
}
export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
@@ -42,6 +49,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -54,6 +62,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
@@ -104,6 +113,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
healthCheckSwarm: hasAnyValue ? formData : null,
});
@@ -29,7 +29,14 @@ export const labelsFormSchema = z.object({
interface LabelsFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
}
export const LabelsForm = ({ id, type }: LabelsFormProps) => {
@@ -45,6 +52,7 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -57,6 +65,7 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
@@ -112,6 +121,7 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
labelsSwarm: labelsToSend,
});
@@ -23,7 +23,14 @@ import { api } from "@/utils/api";
interface ModeFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
}
export const ModeForm = ({ id, type }: ModeFormProps) => {
@@ -39,6 +46,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -51,6 +59,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
@@ -95,6 +104,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
modeSwarm: null,
});
toast.success("Mode updated successfully");
@@ -122,6 +132,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
modeSwarm: modeData,
});
@@ -35,7 +35,14 @@ export const networkFormSchema = z.object({
interface NetworkFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
}
export const NetworkForm = ({ id, type }: NetworkFormProps) => {
@@ -51,6 +58,7 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -63,6 +71,7 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
@@ -132,6 +141,7 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
networkSwarm: networksToSend,
});
@@ -34,7 +34,14 @@ export const placementFormSchema = z.object({
interface PlacementFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
}
export const PlacementForm = ({ id, type }: PlacementFormProps) => {
@@ -50,6 +57,7 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -62,6 +70,7 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
@@ -114,6 +123,7 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
placementSwarm: hasAnyValue
? {
...formData,
@@ -32,7 +32,14 @@ export const restartPolicyFormSchema = z.object({
interface RestartPolicyFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
}
export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
@@ -48,6 +55,7 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -60,6 +68,7 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
@@ -104,6 +113,7 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
restartPolicySwarm: hasAnyValue ? formData : null,
});
@@ -34,7 +34,14 @@ export const rollbackConfigFormSchema = z.object({
interface RollbackConfigFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
}
export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
@@ -50,6 +57,7 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -62,6 +70,7 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
@@ -103,6 +112,7 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
rollbackConfigSwarm: (hasAnyValue ? formData : null) as any,
});
@@ -16,14 +16,21 @@ import { api } from "@/utils/api";
const hasStopGracePeriodSwarm = (
value: unknown,
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
): value is { stopGracePeriodSwarm: number | string | null } =>
typeof value === "object" &&
value !== null &&
"stopGracePeriodSwarm" in value;
interface StopGracePeriodFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
}
export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
@@ -39,6 +46,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -51,6 +59,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
@@ -59,7 +68,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
const form = useForm<any>({
defaultValues: {
value: null as bigint | null,
value: null as number | null,
},
});
@@ -67,11 +76,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
if (hasStopGracePeriodSwarm(data)) {
const value = data.stopGracePeriodSwarm;
const normalizedValue =
value === null || value === undefined
? null
: typeof value === "bigint"
? value
: BigInt(value);
value === null || value === undefined ? null : Number(value);
form.reset({
value: normalizedValue,
});
@@ -88,6 +93,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
stopGracePeriodSwarm: formData.value,
});
@@ -126,7 +132,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
}
onChange={(e) =>
field.onChange(
e.target.value ? BigInt(e.target.value) : null,
e.target.value ? Number(e.target.value) : null,
)
}
/>
@@ -34,7 +34,14 @@ export const updateConfigFormSchema = z.object({
interface UpdateConfigFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
}
export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
@@ -50,6 +57,7 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -62,6 +70,7 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
@@ -109,6 +118,7 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
updateConfigSwarm: (hasAnyValue ? formData : null) as any,
});
@@ -37,13 +37,13 @@ import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
const AddRedirectchema = z.object({
const AddRedirectSchema = z.object({
regex: z.string().min(1, "Regex required"),
permanent: z.boolean().default(false),
replacement: z.string().min(1, "Replacement required"),
});
type AddRedirect = z.infer<typeof AddRedirectchema>;
type AddRedirect = z.infer<typeof AddRedirectSchema>;
// Default presets
const redirectPresets = [
@@ -110,7 +110,7 @@ export const HandleRedirect = ({
regex: "",
replacement: "",
},
resolver: zodResolver(AddRedirectchema),
resolver: zodResolver(AddRedirectSchema),
});
useEffect(() => {
@@ -149,7 +149,7 @@ export const HandleRedirect = ({
const onDialogToggle = (open: boolean) => {
setIsOpen(open);
// commented for the moment because not reseting the form if accidentally closed the dialog can be considered as a feature instead of a bug
// commented for the moment because not resetting the form if accidentally closed the dialog can be considered as a feature instead of a bug
// setPresetSelected("");
// form.reset();
};
@@ -89,12 +89,13 @@ const ULIMIT_PRESETS = [
];
export type ServiceType =
| "postgres"
| "mongo"
| "redis"
| "mysql"
| "application"
| "libsql"
| "mariadb"
| "application";
| "mongo"
| "mysql"
| "postgres"
| "redis";
interface Props {
id: string;
@@ -105,27 +106,29 @@ type AddResources = z.infer<typeof addResourcesSchema>;
export const ShowResources = ({ id, type }: Props) => {
const queryMap = {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
application: () => api.application.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync, isPending } = mutationMap[type]
@@ -155,19 +158,20 @@ export const ShowResources = ({ id, type }: Props) => {
cpuReservation: data?.cpuReservation || undefined,
memoryLimit: data?.memoryLimit || undefined,
memoryReservation: data?.memoryReservation || undefined,
ulimitsSwarm: data?.ulimitsSwarm || [],
ulimitsSwarm: (data as any)?.ulimitsSwarm || [],
});
}
}, [data, form, form.reset]);
const onSubmit = async (formData: AddResources) => {
await mutateAsync({
applicationId: id || "",
libsqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
mysqlId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
applicationId: id || "",
cpuLimit: formData.cpuLimit || null,
cpuReservation: formData.cpuReservation || null,
memoryLimit: formData.memoryLimit || null,
@@ -34,13 +34,13 @@ interface Props {
serviceId: string;
serviceType:
| "application"
| "postgres"
| "redis"
| "mongo"
| "redis"
| "mysql"
| "compose"
| "libsql"
| "mariadb"
| "compose";
| "mongo"
| "mysql"
| "postgres"
| "redis";
refetch: () => void;
children?: React.ReactNode;
}
@@ -29,23 +29,25 @@ export const ShowVolumes = ({ id, type }: Props) => {
if (!canRead) return null;
const queryMap = {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
compose: () =>
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
compose: () =>
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const { mutateAsync: deleteVolume, isPending: isRemoving } =
api.mounts.remove.useMutation();
return (
<Card className="bg-background">
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
@@ -67,13 +67,13 @@ interface Props {
refetch: () => void;
serviceType:
| "application"
| "postgres"
| "redis"
| "mongo"
| "redis"
| "mysql"
| "compose"
| "libsql"
| "mariadb"
| "compose";
| "mongo"
| "mysql"
| "postgres"
| "redis";
}
export const UpdateVolume = ({
@@ -253,7 +253,7 @@ export const UpdateVolume = ({
control={form.control}
name="content"
render={({ field }) => (
<FormItem className="max-w-full max-w-[45rem]">
<FormItem className="w-full max-w-[45rem]">
<FormLabel>Content</FormLabel>
<FormControl>
<FormControl>
@@ -1,6 +1,7 @@
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";
@@ -165,6 +166,7 @@ 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,3 +1,4 @@
import copy from "copy-to-clipboard";
import {
ChevronDown,
ChevronUp,
@@ -11,7 +12,6 @@ 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";
@@ -0,0 +1,303 @@
import type { ColumnDef } from "@tanstack/react-table";
import {
ArrowUpDown,
CheckCircle2,
ExternalLink,
Loader2,
PenBoxIcon,
RefreshCw,
Server,
Trash2,
XCircle,
} from "lucide-react";
import Link from "next/link";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { RouterOutputs } from "@/utils/api";
import type { ValidationStates } from "./show-domains";
import { AddDomain } from "./handle-domain";
import { DnsHelperModal } from "./dns-helper-modal";
export type Domain =
| RouterOutputs["domain"]["byApplicationId"][0]
| RouterOutputs["domain"]["byComposeId"][0];
interface ColumnsProps {
id: string;
type: "application" | "compose";
validationStates: ValidationStates;
handleValidateDomain: (host: string) => Promise<void>;
handleDeleteDomain: (domainId: string) => Promise<void>;
isDeleting: boolean;
serverIp?: string;
canCreateDomain: boolean;
canDeleteDomain: boolean;
}
export const createColumns = ({
id,
type,
validationStates,
handleValidateDomain,
handleDeleteDomain,
isDeleting,
serverIp,
canCreateDomain,
canDeleteDomain,
}: ColumnsProps): ColumnDef<Domain>[] => [
...(type === "compose"
? [
{
accessorKey: "serviceName",
header: "Service",
cell: ({ row }: { row: { getValue: (key: string) => unknown } }) => {
const serviceName = row.getValue("serviceName") as string | null;
if (!serviceName) return null;
return (
<Badge variant="outline">
<Server className="size-3 mr-1" />
{serviceName}
</Badge>
);
},
} satisfies ColumnDef<Domain>,
]
: []),
{
accessorKey: "host",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Host
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const domain = row.original;
return (
<Link
className="flex items-center gap-2 font-medium hover:underline"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
>
{domain.host}
<ExternalLink className="size-3" />
</Link>
);
},
},
{
accessorKey: "path",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Path
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const path = row.getValue("path") as string;
return <div className="font-mono text-sm">{path || "/"}</div>;
},
},
{
accessorKey: "port",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Port
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const port = row.getValue("port") as number;
return <Badge variant="secondary">{port}</Badge>;
},
},
{
accessorKey: "customEntrypoint",
header: "Entrypoint",
cell: ({ row }) => {
const entrypoint = row.getValue("customEntrypoint") as string | null;
if (!entrypoint) return <span className="text-muted-foreground">-</span>;
return <div className="font-mono text-sm">{entrypoint}</div>;
},
},
{
accessorKey: "https",
header: "Protocol",
cell: ({ row }) => {
const https = row.getValue("https") as boolean;
return (
<Badge variant={https ? "outline" : "secondary"}>
{https ? "HTTPS" : "HTTP"}
</Badge>
);
},
},
{
id: "certificate",
header: "Certificate",
cell: ({ row }) => {
const domain = row.original;
const validationState = validationStates[domain.host];
return (
<div className="flex items-center gap-2">
{domain.certificateType && (
<Badge variant="outline" className="capitalize">
{domain.certificateType}
</Badge>
)}
{!domain.host.includes("traefik.me") && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className={
validationState?.isValid
? "bg-green-500/10 text-green-500 cursor-pointer"
: validationState?.error
? "bg-red-500/10 text-red-500 cursor-pointer"
: "bg-yellow-500/10 text-yellow-500 cursor-pointer"
}
onClick={() => handleValidateDomain(domain.host)}
>
{validationState?.isLoading ? (
<>
<Loader2 className="size-3 mr-1 animate-spin" />
Checking...
</>
) : validationState?.isValid ? (
<>
<CheckCircle2 className="size-3 mr-1" />
{validationState.message && validationState.cdnProvider
? `${validationState.cdnProvider}`
: "Valid"}
</>
) : validationState?.error ? (
<>
<XCircle className="size-3 mr-1" />
Invalid
</>
) : (
<>
<RefreshCw className="size-3 mr-1" />
Validate
</>
)}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{validationState?.error ? (
<div className="flex flex-col gap-1">
<p className="font-medium text-red-500">Error:</p>
<p>{validationState.error}</p>
</div>
) : (
"Click to validate DNS configuration"
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
},
},
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Created
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const createdAt = row.getValue("createdAt") as string;
return (
<div className="text-sm text-muted-foreground">
{new Date(createdAt).toLocaleDateString()}
</div>
);
},
},
{
id: "actions",
header: "Actions",
enableHiding: false,
cell: ({ row }) => {
const domain = row.original;
return (
<div className="flex items-center gap-2">
{!domain.host.includes("traefik.me") && (
<DnsHelperModal
domain={{
host: domain.host,
https: domain.https,
path: domain.path || undefined,
}}
serverIp={serverIp}
/>
)}
{canCreateDomain && (
<AddDomain id={id} type={type} domainId={domain.domainId}>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 h-8 w-8"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</AddDomain>
)}
{canDeleteDomain && (
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await handleDeleteDomain(domain.domainId);
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 h-8 w-8"
isLoading={isDeleting}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
);
},
},
];
@@ -1,11 +1,12 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
import { DatabaseZap, Dices, RefreshCw, X } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import z from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -61,11 +62,14 @@ export const domain = z
.min(1, { message: "Port must be at least 1" })
.max(65535, { message: "Port must be 65535 or below" })
.optional(),
useCustomEntrypoint: z.boolean(),
customEntrypoint: z.string().optional(),
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
customCertResolver: z.string().optional(),
serviceName: z.string().optional(),
domainType: z.enum(["application", "compose", "preview"]).optional(),
middlewares: z.array(z.string()).optional(),
})
.superRefine((input, ctx) => {
if (input.https && !input.certificateType) {
@@ -114,6 +118,14 @@ export const domain = z
message: "Internal path must start with '/'",
});
}
if (input.useCustomEntrypoint && !input.customEntrypoint) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["customEntrypoint"],
message: "Custom entry point must be specified",
});
}
});
type Domain = z.infer<typeof domain>;
@@ -196,16 +208,20 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
internalPath: undefined,
stripPath: false,
port: undefined,
useCustomEntrypoint: false,
customEntrypoint: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
serviceName: undefined,
domainType: type,
middlewares: [],
},
mode: "onChange",
});
const certificateType = form.watch("certificateType");
const useCustomEntrypoint = form.watch("useCustomEntrypoint");
const https = form.watch("https");
const domainType = form.watch("domainType");
const host = form.watch("host");
@@ -220,10 +236,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
internalPath: data?.internalPath || undefined,
stripPath: data?.stripPath || false,
port: data?.port || undefined,
useCustomEntrypoint: !!data.customEntrypoint,
customEntrypoint: data.customEntrypoint || undefined,
certificateType: data?.certificateType || undefined,
customCertResolver: data?.customCertResolver || undefined,
serviceName: data?.serviceName || undefined,
domainType: data?.domainType || type,
middlewares: data?.middlewares || [],
});
}
@@ -234,10 +253,13 @@ 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]);
@@ -268,6 +290,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
composeId: id,
}),
...data,
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
})
.then(async () => {
toast.success(dictionary.success);
@@ -635,6 +658,55 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
}}
/>
<FormField
control={form.control}
name="useCustomEntrypoint"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>Custom Entrypoint</FormLabel>
<FormDescription>
Use custom entrypoint for domina
<br />
"web" and/or "websecure" is used by default.
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {
form.setValue("customEntrypoint", undefined);
}
}}
/>
</FormControl>
</FormItem>
)}
/>
{useCustomEntrypoint && (
<FormField
control={form.control}
name="customEntrypoint"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Entrypoint Name</FormLabel>
<FormControl>
<Input
placeholder="Enter entrypoint name manually"
{...field}
className="w-full"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="https"
@@ -725,6 +797,88 @@ 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,8 +1,22 @@
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import {
CheckCircle2,
ChevronDown,
ExternalLink,
GlobeIcon,
InfoIcon,
LayoutGrid,
LayoutList,
Loader2,
PenBoxIcon,
RefreshCw,
@@ -23,6 +37,21 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
@@ -30,6 +59,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { createColumns } from "./columns";
import { DnsHelperModal } from "./dns-helper-modal";
import { AddDomain } from "./handle-domain";
@@ -74,6 +104,19 @@ export const ShowDomains = ({ id, type }: Props) => {
const [validationStates, setValidationStates] = useState<ValidationStates>(
{},
);
const [viewMode, setViewMode] = useState<"grid" | "table">(() => {
if (typeof window !== "undefined") {
return (
(localStorage.getItem("domains-view-mode") as "grid" | "table") ??
"grid"
);
}
return "grid";
});
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
const { data: ip } = api.settings.getIp.useQuery();
const {
@@ -103,6 +146,16 @@ 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,
@@ -140,6 +193,37 @@ export const ShowDomains = ({ id, type }: Props) => {
}
};
const columns = createColumns({
id,
type,
validationStates,
handleValidateDomain,
handleDeleteDomain,
isDeleting: isRemoving,
serverIp: application?.server?.ipAddress?.toString() || ip?.toString(),
canCreateDomain,
canDeleteDomain,
});
const table = useReactTable({
data: data ?? [],
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
return (
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
@@ -151,13 +235,32 @@ export const ShowDomains = ({ id, type }: Props) => {
</CardDescription>
</div>
<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
<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" />
)}
</Button>
</AddDomain>
{canCreateDomain && (
<AddDomain id={id} type={type}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
</AddDomain>
)}
</>
)}
</div>
</CardHeader>
@@ -186,6 +289,122 @@ 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) => {
@@ -341,6 +560,22 @@ export const ShowDomains = ({ id, type }: Props) => {
</TooltipProvider>
)}
{item.middlewares?.map((middleware, index) => (
<TooltipProvider key={`${middleware}-${index}`}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary">
<InfoIcon className="size-3 mr-1" />
Middleware: {middleware}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Traefik middleware reference</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -39,15 +39,16 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canWrite = permissions?.envVars.write ?? false;
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
compose: () =>
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
compose: () =>
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -55,16 +56,17 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const [isEnvVisible, setIsEnvVisible] = useState(true);
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
compose: () => api.compose.update.useMutation(),
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(),
};
const { mutateAsync, isPending } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
: api.mongo.saveEnvironment.useMutation();
const form = useForm<EnvironmentSchema>({
defaultValues: {
@@ -87,12 +89,13 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const onSubmit = async (formData: EnvironmentSchema) => {
mutateAsync({
composeId: id || "",
libsqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
mysqlId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
composeId: id || "",
env: formData.environment,
})
.then(async () => {
@@ -113,7 +116,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !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.key === "s" && !isPending) {
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !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.all.useQuery();
const { data: sshKeys } = api.sshKey.allForApps.useQuery();
const router = useRouter();
const { mutateAsync, isPending } =
@@ -0,0 +1,277 @@
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 containersLenght =
const containersLength =
option === "native" ? containers?.length : services?.length;
return (
@@ -167,7 +167,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
</>
)}
<SelectLabel>Containers ({containersLenght})</SelectLabel>
<SelectLabel>Containers ({containersLength})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
@@ -1,2 +1,2 @@
export * from "./show-patches";
export * from "./patch-editor";
export * from "./show-patches";
@@ -71,6 +71,7 @@ const formSchema = z
"mongo",
"mysql",
"redis",
"libsql",
]),
serviceName: z.string(),
destinationId: z.string().min(1, "Destination required"),
@@ -482,7 +483,7 @@ export const HandleVolumeBackups = ({
</SelectContent>
</Select>
<FormDescription>
Choose the volume to backup, if you dont see the
Choose the volume to backup. If you do not see the
volume here, you can type the volume name manually
</FormDescription>
<FormMessage />
@@ -517,7 +518,7 @@ export const HandleVolumeBackups = ({
</SelectContent>
</Select>
<FormDescription>
Choose the volume to backup, if you dont see the volume
Choose the volume to backup. If you do not see the volume
here, you can type the volume name manually
</FormDescription>
<FormMessage />
@@ -0,0 +1,290 @@
import { Loader2, MoreHorizontal, RefreshCw } from "lucide-react";
import dynamic from "next/dynamic";
import { useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
import { ShowContainerConfig } from "@/components/dashboard/docker/config/show-container-config";
import { ShowContainerMounts } from "@/components/dashboard/docker/mounts/show-container-mounts";
import { ShowContainerNetworks } from "@/components/dashboard/docker/networks/show-container-networks";
import { DockerTerminalModal } from "@/components/dashboard/docker/terminal/docker-terminal-modal";
const DockerLogsId = dynamic(
() =>
import("@/components/dashboard/docker/logs/docker-logs-id").then(
(e) => e.DockerLogsId,
),
{
ssr: false,
},
);
interface Props {
appName: string;
serverId?: string;
appType: "stack" | "docker-compose";
}
export const ShowComposeContainers = ({
appName,
appType,
serverId,
}: Props) => {
const { data, isPending, refetch } =
api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
appType,
serverId,
},
{
enabled: !!appName,
},
);
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-xl">Containers</CardTitle>
<CardDescription>
Inspect each container in this compose and run basic lifecycle
actions.
</CardDescription>
</div>
<Button
variant="outline"
size="icon"
onClick={() => refetch()}
disabled={isPending}
>
<RefreshCw className={`h-4 w-4 ${isPending ? "animate-spin" : ""}`} />
</Button>
</CardHeader>
<CardContent>
{isPending ? (
<div className="flex items-center justify-center h-[20vh]">
<Loader2 className="animate-spin h-6 w-6 text-muted-foreground" />
</div>
) : !data || data.length === 0 ? (
<div className="flex items-center justify-center h-[20vh]">
<span className="text-muted-foreground">
No containers found. Deploy the compose to see containers here.
</span>
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>State</TableHead>
<TableHead>Status</TableHead>
<TableHead>Container ID</TableHead>
<TableHead className="text-right" />
</TableRow>
</TableHeader>
<TableBody>
{data.map((container) => (
<ContainerRow
key={container.containerId}
container={container}
serverId={serverId}
onActionComplete={() => refetch()}
/>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
);
};
interface ContainerRowProps {
container: {
containerId: string;
name: string;
state: string;
status: string;
};
serverId?: string;
onActionComplete: () => void;
}
const ContainerRow = ({
container,
serverId,
onActionComplete,
}: ContainerRowProps) => {
const [logsOpen, setLogsOpen] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const restartMutation = api.docker.restartContainer.useMutation();
const startMutation = api.docker.startContainer.useMutation();
const stopMutation = api.docker.stopContainer.useMutation();
const killMutation = api.docker.killContainer.useMutation();
const handleAction = async (
action: string,
mutationFn: typeof restartMutation,
) => {
setActionLoading(action);
try {
await mutationFn.mutateAsync({
containerId: container.containerId,
serverId,
});
toast.success(`Container ${action} successfully`);
onActionComplete();
} catch (error) {
toast.error(
`Failed to ${action} container: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
setActionLoading(null);
}
};
return (
<TableRow>
<TableCell className="font-medium">{container.name}</TableCell>
<TableCell>
<Badge
variant={
container.state === "running"
? "default"
: container.state === "exited"
? "secondary"
: "destructive"
}
>
{container.state}
</Badge>
</TableCell>
<TableCell>{container.status}</TableCell>
<TableCell className="font-mono text-sm text-muted-foreground">
{container.containerId}
</TableCell>
<TableCell className="text-right">
<Dialog open={logsOpen} onOpenChange={setLogsOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
{actionLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DialogTrigger asChild>
<DropdownMenuItem
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
View Logs
</DropdownMenuItem>
</DialogTrigger>
<ShowContainerConfig
containerId={container.containerId}
serverId={serverId || ""}
/>
<ShowContainerMounts
containerId={container.containerId}
serverId={serverId || ""}
/>
<ShowContainerNetworks
containerId={container.containerId}
serverId={serverId || ""}
/>
<DockerTerminalModal
containerId={container.containerId}
serverId={serverId || ""}
>
Terminal
</DockerTerminalModal>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("restart", restartMutation)}
>
Restart
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("start", startMutation)}
>
Start
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("stop", stopMutation)}
>
Stop
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer text-red-500 focus:text-red-600"
disabled={actionLoading !== null}
onClick={() => handleAction("kill", killMutation)}
>
Kill
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent className="sm:max-w-7xl">
<DialogHeader>
<DialogTitle>View Logs</DialogTitle>
<DialogDescription>Logs for {container.name}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2.5">
<DockerLogsId
containerId={container.containerId}
serverId={serverId}
runType="native"
/>
</div>
</DialogContent>
</Dialog>
</TableCell>
</TableRow>
);
};
@@ -57,6 +57,7 @@ export const DeleteService = ({ id, type }: Props) => {
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
@@ -72,6 +73,7 @@ export const DeleteService = ({ id, type }: Props) => {
redis: () => api.redis.remove.useMutation(),
mysql: () => api.mysql.remove.useMutation(),
mariadb: () => api.mariadb.remove.useMutation(),
libsql: () => api.libsql.remove.useMutation(),
application: () => api.application.delete.useMutation(),
mongo: () => api.mongo.remove.useMutation(),
compose: () => api.compose.delete.useMutation(),
@@ -98,6 +100,7 @@ export const DeleteService = ({ id, type }: Props) => {
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
libsqlId: id || "",
applicationId: id || "",
composeId: id || "",
deleteVolumes,
@@ -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.key === "s" && !isPending) {
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !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.all.useQuery();
const { data: sshKeys } = api.sshKey.allForApps.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 } from "lucide-react";
import { CheckIcon, ChevronsUpDown, Plus, X, HelpCircle } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -409,10 +409,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
<p>
@@ -77,7 +77,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
}, [option, services, containers]);
const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLenght =
const containersLength =
option === "native" ? containers?.length : services?.length;
return (
@@ -152,7 +152,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
</>
)}
<SelectLabel>Containers ({containersLenght})</SelectLabel>
<SelectLabel>Containers ({containersLength})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
@@ -65,7 +65,13 @@ import { ScheduleFormField } from "../../application/schedules/handle-schedules"
type CacheType = "cache" | "fetch";
type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
type DatabaseType =
| "postgres"
| "mariadb"
| "mysql"
| "mongo"
| "web-server"
| "libsql";
const Schema = z
.object({
@@ -77,7 +83,7 @@ const Schema = z
keepLatestCount: z.coerce.number().optional(),
serviceName: z.string().nullable(),
databaseType: z
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server", "libsql"])
.optional(),
backupType: z.enum(["database", "compose"]),
metadata: z
@@ -209,7 +215,12 @@ export const HandleBackup = ({
const form = useForm({
defaultValues: {
database: databaseType === "web-server" ? "dokploy" : "",
database:
databaseType === "web-server"
? "dokploy"
: databaseType === "libsql"
? "iku.db"
: "",
destinationId: "",
enabled: true,
prefix: "/",
@@ -246,7 +257,9 @@ export const HandleBackup = ({
? backup?.database
: databaseType === "web-server"
? "dokploy"
: "",
: databaseType === "libsql"
? "iku.db"
: "",
destinationId: backup?.destinationId ?? "",
enabled: backup?.enabled ?? true,
prefix: backup?.prefix ?? "/",
@@ -281,11 +294,15 @@ export const HandleBackup = ({
? {
mongoId: id,
}
: databaseType === "web-server"
: databaseType === "libsql"
? {
userId: id,
libsqlId: id,
}
: undefined;
: databaseType === "web-server"
? {
userId: id,
}
: undefined;
await createBackup({
destinationId: data.destinationId,
@@ -568,7 +585,10 @@ export const HandleBackup = ({
<FormLabel>Database</FormLabel>
<FormControl>
<Input
disabled={databaseType === "web-server"}
disabled={
databaseType === "web-server" ||
databaseType === "libsql"
}
placeholder={"dokploy"}
{...field}
/>
@@ -88,7 +88,7 @@ const RestoreBackupSchema = z
message: "Database name is required",
}),
databaseType: z
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server", "libsql"])
.optional(),
backupType: z.enum(["database", "compose"]).default("database"),
metadata: z
@@ -211,7 +211,12 @@ export const RestoreBackup = ({
defaultValues: {
destinationId: "",
backupFile: "",
databaseName: databaseType === "web-server" ? "dokploy" : "",
databaseName:
databaseType === "web-server"
? "dokploy"
: databaseType === "libsql"
? "iku.db"
: "",
databaseType:
backupType === "compose" ? ("postgres" as DatabaseType) : databaseType,
backupType: backupType,
@@ -220,7 +225,7 @@ export const RestoreBackup = ({
resolver: zodResolver(RestoreBackupSchema),
});
const destionationId = form.watch("destinationId");
const destinationId = form.watch("destinationId");
const currentDatabaseType = form.watch("databaseType");
const metadata = form.watch("metadata");
@@ -235,12 +240,12 @@ export const RestoreBackup = ({
const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery(
{
destinationId: destionationId,
destinationId: destinationId,
search: debouncedSearchTerm,
serverId: serverId ?? "",
},
{
enabled: isOpen && !!destionationId,
enabled: isOpen && !!destinationId,
},
);
@@ -523,7 +528,10 @@ export const RestoreBackup = ({
<Input
placeholder="Enter database name"
{...field}
disabled={databaseType === "web-server"}
disabled={
databaseType === "web-server" ||
databaseType === "libsql"
}
/>
</FormControl>
<FormMessage />
@@ -53,14 +53,16 @@ export const ShowBackups = ({
const queryMap =
backupType === "database"
? {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
mysql: () =>
api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () =>
api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
mysql: () =>
api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
libsql: () =>
api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
"web-server": () => api.user.getBackups.useQuery(),
}
: {
@@ -77,10 +79,11 @@ export const ShowBackups = ({
const mutationMap =
backupType === "database"
? {
postgres: api.backup.manualBackupPostgres.useMutation(),
mysql: api.backup.manualBackupMySql.useMutation(),
mariadb: api.backup.manualBackupMariadb.useMutation(),
mongo: api.backup.manualBackupMongo.useMutation(),
mysql: api.backup.manualBackupMySql.useMutation(),
postgres: api.backup.manualBackupPostgres.useMutation(),
libsql: api.backup.manualBackupLibsql.useMutation(),
"web-server": api.backup.manualBackupWebServer.useMutation(),
}
: {
@@ -1,8 +1,8 @@
"use client";
import type { inferRouterOutputs } from "@trpc/server";
import Link from "next/link";
import { ArrowRight, ListTodo, Loader2, XCircle } from "lucide-react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -0,0 +1,189 @@
"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,6 +12,7 @@ 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";
@@ -377,6 +378,7 @@ 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 colapsible later */}
{/* Icon to expand the log item maybe implement a collapsible later */}
{/* <Square className="size-4 text-muted-foreground opacity-0 group-hover/logitem:opacity-100 transition-opacity" /> */}
{tooltip(color, rawTimestamp)}
{!noTimestamp && (
@@ -74,6 +74,18 @@ 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 (
@@ -0,0 +1,112 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { api } from "@/utils/api";
interface Props {
containerId: string;
serverId?: string;
}
interface Mount {
Type: string;
Source: string;
Destination: string;
Mode: string;
RW: boolean;
Propagation: string;
Name?: string;
Driver?: string;
}
export const ShowContainerMounts = ({ containerId, serverId }: Props) => {
const { data } = api.docker.getConfig.useQuery(
{
containerId,
serverId,
},
{
enabled: !!containerId,
},
);
const mounts: Mount[] = data?.Mounts ?? [];
return (
<Dialog>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
View Mounts
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Container Mounts</DialogTitle>
<DialogDescription>
Volume and bind mounts for this container
</DialogDescription>
</DialogHeader>
<div className="overflow-auto max-h-[70vh]">
{mounts.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
No mounts found for this container.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Source</TableHead>
<TableHead>Destination</TableHead>
<TableHead>Mode</TableHead>
<TableHead>Read/Write</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mounts.map((mount, index) => (
<TableRow key={index}>
<TableCell>
<Badge variant="outline">{mount.Type}</Badge>
</TableCell>
<TableCell className="font-mono text-xs max-w-[250px] truncate">
{mount.Name || mount.Source}
</TableCell>
<TableCell className="font-mono text-xs max-w-[250px] truncate">
{mount.Destination}
</TableCell>
<TableCell className="text-xs">
{mount.Mode || "-"}
</TableCell>
<TableCell>
<Badge variant={mount.RW ? "default" : "secondary"}>
{mount.RW ? "RW" : "RO"}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,119 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { api } from "@/utils/api";
interface Props {
containerId: string;
serverId?: string;
}
interface Network {
IPAMConfig: unknown;
Links: unknown;
Aliases: string[] | null;
MacAddress: string;
NetworkID: string;
EndpointID: string;
Gateway: string;
IPAddress: string;
IPPrefixLen: number;
IPv6Gateway: string;
GlobalIPv6Address: string;
GlobalIPv6PrefixLen: number;
DriverOpts: unknown;
}
export const ShowContainerNetworks = ({ containerId, serverId }: Props) => {
const { data } = api.docker.getConfig.useQuery(
{
containerId,
serverId,
},
{
enabled: !!containerId,
},
);
const networks: Record<string, Network> =
data?.NetworkSettings?.Networks ?? {};
const entries = Object.entries(networks);
return (
<Dialog>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
View Networks
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Container Networks</DialogTitle>
<DialogDescription>
Networks attached to this container
</DialogDescription>
</DialogHeader>
<div className="overflow-auto max-h-[70vh]">
{entries.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
No networks found for this container.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Network</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>Gateway</TableHead>
<TableHead>MAC Address</TableHead>
<TableHead>Aliases</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map(([name, network]) => (
<TableRow key={name}>
<TableCell>
<Badge variant="outline">{name}</Badge>
</TableCell>
<TableCell className="font-mono text-xs">
{network.IPAddress
? `${network.IPAddress}/${network.IPPrefixLen}`
: "-"}
</TableCell>
<TableCell className="font-mono text-xs">
{network.Gateway || "-"}
</TableCell>
<TableCell className="font-mono text-xs">
{network.MacAddress || "-"}
</TableCell>
<TableCell className="text-xs">
{network.Aliases?.join(", ") || "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,66 @@
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { api } from "@/utils/api";
interface Props {
containerId: string;
serverId?: string;
}
export const RemoveContainerDialog = ({ containerId, serverId }: Props) => {
const utils = api.useUtils();
const { mutateAsync, isPending } = api.docker.removeContainer.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) => e.preventDefault()}
>
Remove Container
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove the container{" "}
<span className="font-semibold">{containerId}</span>. If the
container is running, it will be forcefully stopped and removed.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={isPending}
onClick={async () => {
await mutateAsync({ containerId, serverId })
.then(async () => {
toast.success("Container removed successfully");
await utils.docker.getContainers.invalidate();
})
.catch((err) => {
toast.error(err.message);
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
@@ -10,7 +10,11 @@ import {
} from "@/components/ui/dropdown-menu";
import { ShowContainerConfig } from "../config/show-container-config";
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
import { ShowContainerMounts } from "../mounts/show-container-mounts";
import { ShowContainerNetworks } from "../networks/show-container-networks";
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>[] = [
@@ -121,12 +125,30 @@ export const columns: ColumnDef<Container>[] = [
containerId={container.containerId}
serverId={container.serverId || ""}
/>
<ShowContainerMounts
containerId={container.containerId}
serverId={container.serverId || ""}
/>
<ShowContainerNetworks
containerId={container.containerId}
serverId={container.serverId || ""}
/>
<DockerTerminalModal
containerId={container.containerId}
serverId={container.serverId || ""}
>
Terminal
</DockerTerminalModal>
<UploadFileModal
containerId={container.containerId}
serverId={container.serverId || undefined}
>
Upload File
</UploadFileModal>
<RemoveContainerDialog
containerId={container.containerId}
serverId={container.serverId ?? undefined}
/>
</DropdownMenuContent>
</DropdownMenu>
);
@@ -35,7 +35,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { api, type RouterOutputs } from "@/utils/api";
import { columns } from "./colums";
import { columns } from "./columns";
export type Container = NonNullable<
RouterOutputs["docker"]["getContainers"]
>[0];
@@ -0,0 +1,187 @@
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>
);
};
@@ -0,0 +1,251 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => {
if (a === null || a === undefined || a === "") return null;
const parsed = Number.parseInt(String(a), 10);
return Number.isNaN(parsed) ? null : parsed;
}, z
.number()
.gte(0, "Range must be 0 - 65535")
.lte(65535, "Range must be 0 - 65535")
.nullable()),
externalGRPCPort: z.preprocess((a) => {
if (a === null || a === undefined || a === "") return null;
const parsed = Number.parseInt(String(a), 10);
return Number.isNaN(parsed) ? null : parsed;
}, z
.number()
.gte(0, "Range must be 0 - 65535")
.lte(65535, "Range must be 0 - 65535")
.nullable()),
externalAdminPort: z.preprocess((a) => {
if (a === null || a === undefined || a === "") return null;
const parsed = Number.parseInt(String(a), 10);
return Number.isNaN(parsed) ? null : parsed;
}, z
.number()
.gte(0, "Range must be 0 - 65535")
.lte(65535, "Range must be 0 - 65535")
.nullable()),
});
type DockerProvider = z.infer<typeof DockerProviderSchema>;
interface Props {
libsqlId: string;
}
export const ShowExternalLibsqlCredentials = ({ libsqlId }: Props) => {
const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.libsql.one.useQuery({ libsqlId });
const { mutateAsync, isPending } = api.libsql.saveExternalPorts.useMutation();
const [connectionUrl, setConnectionUrl] = useState("");
const [connectionGRPCUrl, setGRPCConnectionUrl] = useState("");
const getIp = data?.server?.ipAddress || ip;
const form = useForm({
defaultValues: {},
resolver: zodResolver(DockerProviderSchema),
});
useEffect(() => {
if (data) {
form.reset({
externalPort: data.externalPort,
externalGRPCPort: data.externalGRPCPort,
externalAdminPort: data.externalAdminPort,
});
}
}, [form.reset, data, form]);
const onSubmit = async (values: DockerProvider) => {
await mutateAsync({
externalPort: values.externalPort,
externalGRPCPort: values.externalGRPCPort,
externalAdminPort: values.externalAdminPort,
libsqlId,
})
.then(async () => {
toast.success("External port/ports updated");
await refetch();
})
.catch((error: Error) => {
toast.error(error?.message || "Error saving the external port/ports");
});
};
useEffect(() => {
const port = form.watch("externalPort") || data?.externalPort;
setConnectionUrl(
`http://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`,
);
if (data?.sqldNode !== "replica") {
const grpcPort = form.watch("externalGRPCPort") || data?.externalGRPCPort;
setGRPCConnectionUrl(
`http://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${grpcPort}`,
);
}
}, [
data?.externalGRPCPort,
data?.databasePassword,
form,
data?.databaseUser,
getIp,
]);
return (
<div className="flex w-full flex-col gap-5">
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription>
In order to make the database reachable through the internet, you
must set a port and ensure that the port is not being used by
another application or database
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">
{!getIp && (
<AlertBlock type="warning">
You need to set an IP address in your{" "}
<Link href="/dashboard/settings/server" className="text-primary">
{data?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"}
</Link>{" "}
to fix the database url connection.
</AlertBlock>
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 space-y-4">
<FormField
control={form.control}
name="externalPort"
render={({ field }) => (
<FormItem>
<FormLabel>External Port (Internet)</FormLabel>
<FormControl>
<Input
placeholder="8080"
{...field}
value={field.value as string}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{!!data?.externalPort && (
<div className="grid w-full gap-8">
<div className="flex flex-col gap-3">
<Label>External Host</Label>
<ToggleVisibilityInput value={connectionUrl} disabled />
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 space-y-4">
<FormField
control={form.control}
name="externalAdminPort"
render={({ field }) => (
<FormItem>
<FormLabel>External Admin Port (Internet)</FormLabel>
<FormControl>
<Input
placeholder="5000"
{...field}
value={field.value as string}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{data?.sqldNode !== "replica" && (
<>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 space-y-4">
<FormField
control={form.control}
name="externalGRPCPort"
render={({ field }) => (
<FormItem>
<FormLabel>External GRPC Port (Internet)</FormLabel>
<FormControl>
<Input
placeholder="5001"
{...field}
value={field.value as string}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{!!data?.externalGRPCPort && (
<div className="grid w-full gap-8">
<div className="flex flex-col gap-3">
<Label>External GRPC Host</Label>
<ToggleVisibilityInput
value={connectionGRPCUrl}
disabled
/>
</div>
</div>
)}
</>
)}
<div className="flex justify-end">
<Button type="submit" isLoading={isPending}>
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
);
};
@@ -0,0 +1,268 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { type LogLine, parseLogs } from "../../docker/logs/utils";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
interface Props {
libsqlId: string;
}
export const ShowGeneralLibsql = ({ libsqlId }: Props) => {
const { data, refetch } = api.libsql.one.useQuery(
{
libsqlId,
},
{ enabled: !!libsqlId },
);
const { mutateAsync: reload, isPending: isReloading } =
api.libsql.reload.useMutation();
const { mutateAsync: start, isPending: isStarting } =
api.libsql.start.useMutation();
const { mutateAsync: stop, isPending: isStopping } =
api.libsql.stop.useMutation();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [isDeploying, setIsDeploying] = useState(false);
api.libsql.deployWithLogs.useSubscription(
{
libsqlId: libsqlId,
},
{
enabled: isDeploying,
onData(log) {
if (!isDrawerOpen) {
setIsDrawerOpen(true);
}
if (log === "Deployment completed successfully!") {
setIsDeploying(false);
}
const parsedLogs = parseLogs(log);
setFilteredLogs((prev) => [...prev, ...parsedLogs]);
},
onError(error) {
console.error("Deployment logs error:", error);
setIsDeploying(false);
},
},
);
return (
<>
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}>
<DialogAction
title="Deploy Libsql"
description="Are you sure you want to deploy this Libsql?"
type="default"
onClick={async () => {
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads and sets up the Libsql database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<DialogAction
title="Reload Libsql"
description="Are you sure you want to reload this libsql?"
type="default"
onClick={async () => {
await reload({
libsqlId: libsqlId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Libsql reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading Libsql");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the Libsql service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
</TooltipProvider>
{data?.applicationStatus === "idle" ? (
<TooltipProvider delayDuration={0}>
<DialogAction
title="Start Libsql"
description="Are you sure you want to start this Libsql?"
type="default"
onClick={async () => {
await start({
libsqlId: libsqlId,
})
.then(() => {
toast.success("Libsql started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Libsql");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the Libsql database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
</TooltipProvider>
) : (
<TooltipProvider delayDuration={0}>
<DialogAction
title="Stop Libsql"
description="Are you sure you want to stop this Libsql?"
onClick={async () => {
await stop({
libsqlId: libsqlId,
})
.then(() => {
toast.success("Libsql stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Libsql");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running Libsql database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
</TooltipProvider>
)}
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}
>
<Button
variant="outline"
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Terminal className="size-4 mr-1" />
Open Terminal
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Open a terminal to the Libsql container</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DockerTerminalModal>
</CardContent>
</Card>
<DrawerLogs
isOpen={isDrawerOpen}
onClose={() => {
setIsDrawerOpen(false);
setFilteredLogs([]);
setIsDeploying(false);
refetch();
}}
filteredLogs={filteredLogs}
/>
</div>
</>
);
};
@@ -0,0 +1,121 @@
import { SelectGroup } from "@radix-ui/react-select";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
interface Props {
libsqlId: string;
}
export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => {
const { data } = api.libsql.one.useQuery({ libsqlId });
return (
<>
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Internal Credentials</CardTitle>
</CardHeader>
<CardContent className="flex w-full flex-row gap-4">
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2">
<Label>User</Label>
<Input disabled value={data?.databaseUser} />
</div>
<div className="flex flex-col gap-2">
<Label>Sqld Node</Label>
<Select value={data?.sqldNode} disabled>
<SelectTrigger>
<SelectValue placeholder="Select Node type" />
</SelectTrigger>
<SelectContent>
{["primary", "replica"].map((node) => (
<SelectItem key={node} value={node}>
{node.charAt(0).toUpperCase() + node.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<ToggleVisibilityInput
disabled
value={data?.databasePassword}
/>
</div>
</div>
<div className="flex flex-row gap-2">
<div className="w-full flex flex-col gap-2">
<Label>Internal Port (Container)</Label>
<Input disabled value="8080" />
</div>
<div className="w-full flex flex-col gap-2">
<Label>Internal GRPC Port (Container)</Label>
<Input disabled value="5001" />
</div>
<div className="w-full flex flex-col gap-2">
<Label>Internal Admin Port (Container)</Label>
<Input disabled value="5000" />
</div>
</div>
<div className="flex flex-col gap-2">
<Label>Internal Host</Label>
<Input disabled value={data?.appName} />
</div>
<div className="flex flex-col gap-2">
<Label>Enable Namespaces</Label>
<Select
disabled
defaultValue={
data?.enableNamespaces
? String(data?.enableNamespaces)
: "false"
}
>
<SelectTrigger>
<SelectValue placeholder={"false"} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{["false", "true"].map((node) => (
<SelectItem key={node} value={node}>
{node.charAt(0).toUpperCase() + node.slice(1)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2 md:col-span-2">
<Label>Internal Connection URL </Label>
<ToggleVisibilityInput
disabled
value={`http://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:8080`}
/>
</div>
<div className="flex flex-col gap-2 md:col-span-2">
<Label>Internal Replication Connection URL </Label>
<ToggleVisibilityInput
disabled
value={`http://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:5001`}
/>
</div>
</div>
</CardContent>
</Card>
</div>
</>
);
};
@@ -0,0 +1,163 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
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";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
const updateLibsqlSchema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
description: z.string().optional(),
});
type UpdateLibsql = z.infer<typeof updateLibsqlSchema>;
interface Props {
libsqlId: string;
}
export const UpdateLibsql = ({ libsqlId }: Props) => {
const utils = api.useUtils();
const { mutateAsync, error, isError, isPending } =
api.libsql.update.useMutation();
const { data } = api.libsql.one.useQuery(
{
libsqlId,
},
{
enabled: !!libsqlId,
},
);
const form = useForm<UpdateLibsql>({
defaultValues: {
description: data?.description ?? "",
name: data?.name ?? "",
},
resolver: zodResolver(updateLibsqlSchema),
});
useEffect(() => {
if (data) {
form.reset({
description: data.description ?? "",
name: data.name,
});
}
}, [data, form, form.reset]);
const onSubmit = async (formData: UpdateLibsql) => {
await mutateAsync({
name: formData.name,
libsqlId: libsqlId,
description: formData.description || "",
})
.then(() => {
toast.success("Libsql updated successfully");
utils.libsql.one.invalidate({
libsqlId: libsqlId,
});
})
.catch(() => {
toast.error("Error updating the Libsql");
})
.finally(() => {});
};
return (
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Modify Libsql</DialogTitle>
<DialogDescription>Update the Libsql data</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="grid gap-4">
<div className="grid items-center gap-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-update-libsql"
className="grid w-full gap-4 "
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Vandelay Industries" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Description about your project..."
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
isLoading={isPending}
form="hook-form-update-libsql"
type="submit"
>
Update
</Button>
</DialogFooter>
</form>
</Form>
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -1,14 +1,19 @@
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 ">
@@ -28,20 +33,43 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
</div>
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<div className="flex flex-row gap-2 items-center">
<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-4">
<div className="flex flex-row gap-2 items-center">
<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,7 +82,8 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
const buildConnectionUrl = () => {
const port = form.watch("externalPort") || data?.externalPort;
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`;
const params = `authSource=admin${data?.replicaSets ? "" : "&directConnection=true"}`;
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/?${params}`;
};
setConnectionUrl(buildConnectionUrl());
@@ -1,14 +1,19 @@
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 ">
@@ -25,11 +30,21 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<div className="flex flex-row gap-2 items-center">
<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>
@@ -47,7 +62,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<Label>Internal Connection URL </Label>
<ToggleVisibilityInput
disabled
value={`mongodb://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:27017`}
value={`mongodb://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:27017/?authSource=admin${data?.replicaSets ? "" : "&directConnection=true"}`}
/>
</div>
</div>
@@ -1,103 +1,103 @@
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
YAxis,
} from "recharts";
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props {
acummulativeData: DockerStatsJSON["block"];
accumulativeData: DockerStatsJSON["block"];
}
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,
};
});
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,
}));
return (
<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>
<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>
);
};
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,87 +1,81 @@
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
YAxis,
} from "recharts";
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props {
acummulativeData: DockerStatsJSON["cpu"];
accumulativeData: DockerStatsJSON["cpu"];
}
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],
};
});
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],
}));
return (
<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>
<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>
);
};
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,105 +1,82 @@
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
YAxis,
} from "recharts";
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props {
acummulativeData: DockerStatsJSON["disk"];
accumulativeData: DockerStatsJSON["disk"];
diskTotal: number;
}
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,
};
});
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,
}));
return (
<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>
<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>
);
};
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;
};
@@ -0,0 +1,182 @@
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,93 +1,87 @@
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
YAxis,
} from "recharts";
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { DockerStatsJSON } from "./show-free-container-monitoring";
import { convertMemoryToBytes } from "./show-free-container-monitoring";
interface Props {
acummulativeData: DockerStatsJSON["memory"];
accumulativeData: DockerStatsJSON["memory"];
memoryLimitGB: number;
}
const chartConfig = {
usage: {
label: "Memory (GB)",
color: "hsl(var(--chart-2))",
},
} satisfies ChartConfig;
export const DockerMemoryChart = ({
acummulativeData,
accumulativeData,
memoryLimitGB,
}: Props) => {
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),
};
});
const transformedData = accumulativeData.map((item, index) => ({
time: item.time,
name: `Point ${index + 1}`,
// @ts-ignore
usage: (convertMemoryToBytes(item.value.used) / 1024 ** 3).toFixed(2),
}));
return (
<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>
<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>
);
};
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 {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
YAxis,
} from "recharts";
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props {
acummulativeData: DockerStatsJSON["network"];
accumulativeData: DockerStatsJSON["network"];
}
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,
};
});
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,
}));
return (
<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>
<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>
);
};
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,6 +5,7 @@ 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";
@@ -124,7 +125,7 @@ export const ContainerFreeMonitoring = ({
refetchOnWindowFocus: false,
},
);
const [acummulativeData, setAcummulativeData] = useState<DockerStatsJSON>({
const [accumulativeData, setAccumulativeData] = useState<DockerStatsJSON>({
cpu: [],
memory: [],
block: [],
@@ -136,7 +137,7 @@ export const ContainerFreeMonitoring = ({
useEffect(() => {
setCurrentData(defaultData);
setAcummulativeData({
setAccumulativeData({
cpu: [],
memory: [],
block: [],
@@ -155,7 +156,7 @@ export const ContainerFreeMonitoring = ({
network: data.network[data.network.length - 1] ?? currentData.network,
disk: data.disk[data.disk.length - 1] ?? currentData.disk,
});
setAcummulativeData({
setAccumulativeData({
block: data?.block || [],
cpu: data?.cpu || [],
disk: data?.disk || [],
@@ -184,7 +185,7 @@ export const ContainerFreeMonitoring = ({
setCurrentData(data);
const MAX_DATA_POINTS = 300;
setAcummulativeData((prevData) => ({
setAccumulativeData((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),
@@ -219,16 +220,16 @@ export const ContainerFreeMonitoring = ({
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
Used: {currentData.cpu.value}
Used: {String(currentData.cpu.value ?? "0%")}
</span>
<Progress
value={Number.parseInt(
currentData.cpu.value.replace("%", ""),
String(currentData.cpu.value ?? "0%").replace("%", ""),
10,
)}
className="w-[100%]"
/>
<DockerCpuChart acummulativeData={acummulativeData.cpu} />
<DockerCpuChart accumulativeData={accumulativeData.cpu} />
</div>
</CardContent>
</Card>
@@ -252,7 +253,7 @@ export const ContainerFreeMonitoring = ({
className="w-[100%]"
/>
<DockerMemoryChart
acummulativeData={acummulativeData.memory}
accumulativeData={accumulativeData.memory}
memoryLimitGB={
// @ts-ignore
convertMemoryToBytes(currentData.memory.value.total) /
@@ -277,13 +278,25 @@ export const ContainerFreeMonitoring = ({
className="w-[100%]"
/>
<DockerDiskChart
acummulativeData={acummulativeData.disk}
accumulativeData={accumulativeData.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">
@@ -294,7 +307,7 @@ export const ContainerFreeMonitoring = ({
<span className="text-sm text-muted-foreground">
{`Read: ${currentData.block.value.readMb} / Write: ${currentData.block.value.writeMb} `}
</span>
<DockerBlockChart acummulativeData={acummulativeData.block} />
<DockerBlockChart accumulativeData={accumulativeData.block} />
</div>
</CardContent>
</Card>
@@ -307,7 +320,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 acummulativeData={acummulativeData.network} />
<DockerNetworkChart accumulativeData={accumulativeData.network} />
</div>
</CardContent>
</Card>
@@ -1,14 +1,19 @@
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 ">
@@ -28,20 +33,43 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
</div>
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<div className="flex flex-row gap-2 items-center">
<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-4">
<div className="flex flex-row gap-2 items-center">
<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">
@@ -42,6 +42,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
@@ -56,6 +57,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
@@ -84,7 +86,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
form.reset({
dockerImage: data.dockerImage,
command: data.command || "",
args: data.args?.map((arg) => ({ value: arg })) || [],
args: (data as any).args?.map((arg: string) => ({ value: arg })) || [],
});
}
}, [data, form]);
@@ -95,6 +97,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
libsqlId: id || "",
mariadbId: id || "",
dockerImage: formData?.dockerImage,
command: formData?.command,
@@ -144,7 +147,14 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
<FormItem>
<FormLabel>Command</FormLabel>
<FormControl>
<Input placeholder="/bin/sh" {...field} />
<Input
placeholder={
type === "libsql"
? "sqld --db-path iku.db --http-listen-addr 0.0.0.0:8080 --grpc-listen-addr 0.0.0.0:5001 --admin-listen-addr 0.0.0.0:5000"
: "Custom command"
}
{...field}
/>
</FormControl>
<FormMessage />
@@ -1,14 +1,19 @@
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 ">
@@ -28,11 +33,21 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
</div>
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<div className="flex flex-row gap-2 items-center">
<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,6 +43,7 @@ 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, {
@@ -53,9 +54,8 @@ const AddTemplateSchema = z.object({
.min(1, {
message: "App name is required",
})
.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 '-'",
.regex(APP_NAME_REGEX, {
message: APP_NAME_MESSAGE,
}),
description: z.string().optional(),
serverId: z.string().optional(),
@@ -43,6 +43,7 @@ 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(),
@@ -54,9 +55,8 @@ const AddComposeSchema = z.object({
.min(1, {
message: "App name is required",
})
.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 '-'",
.regex(APP_NAME_REGEX, {
message: APP_NAME_MESSAGE,
}),
description: z.string().optional(),
serverId: z.string().optional(),
@@ -78,9 +78,6 @@ 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)
@@ -117,6 +114,8 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
await utils.environment.one.invalidate({
environmentId,
});
// Invalidate the project query to refresh the project data for the advance-breadcrumb
await utils.project.all.invalidate();
})
.catch(() => {
toast.error("Error creating the compose");
@@ -5,6 +5,7 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import {
LibsqlIcon,
MariadbIcon,
MongodbIcon,
MysqlIcon,
@@ -51,11 +52,13 @@ 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:7",
mongo: "mongo:8",
libsql: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
mariadb: "mariadb:11",
mysql: "mysql:8",
postgres: "postgres:18",
@@ -66,8 +69,9 @@ const databasesUserDefaultPlaceholder: Record<
Exclude<DbType, "redis">,
string
> = {
mongo: "mongo",
libsql: "libsql",
mariadb: "mariadb",
mongo: "mongo",
mysql: "mysql",
postgres: "postgres",
};
@@ -79,9 +83,8 @@ const baseDatabaseSchema = z.object({
.min(1, {
message: "App name is required",
})
.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 '-'",
.regex(APP_NAME_REGEX, {
message: APP_NAME_MESSAGE,
}),
databasePassword: z
.string()
@@ -94,56 +97,88 @@ const baseDatabaseSchema = z.object({
serverId: z.string().nullable(),
});
const mySchema = z.discriminatedUnion("type", [
z
.object({
type: z.literal("postgres"),
databaseName: z.string().default("postgres"),
databaseUser: z.string().default("postgres"),
})
.merge(baseDatabaseSchema),
z
.object({
type: z.literal("mongo"),
databaseUser: z.string().default("mongo"),
replicaSets: z.boolean().default(false),
})
.merge(baseDatabaseSchema),
z
.object({
type: z.literal("redis"),
})
.merge(baseDatabaseSchema),
z
.object({
type: z.literal("mysql"),
databaseRootPassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
const mySchema = z
.discriminatedUnion("type", [
z
.object({
type: z.literal("libsql"),
dockerImage: z
.string()
.default("ghcr.io/tursodatabase/libsql-server:v0.24.32"),
databaseUser: z.string().default("libsql"),
sqldNode: z.enum(["primary", "replica"]).default("primary"),
sqldPrimaryUrl: z.string().optional(),
enableNamespaces: z.boolean().default(false),
})
.merge(baseDatabaseSchema),
z
.object({
type: z.literal("mariadb"),
dockerImage: z.string().default("mariadb:4"),
databaseRootPassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
})
.optional(),
databaseUser: z.string().default("mariadb"),
databaseName: z.string().default("mariadb"),
})
.merge(baseDatabaseSchema),
z
.object({
type: z.literal("mongo"),
databaseUser: z.string().default("mongo"),
replicaSets: z.boolean().default(false),
})
.merge(baseDatabaseSchema),
z
.object({
type: z.literal("mysql"),
databaseRootPassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
})
.optional(),
databaseUser: z.string().default("mysql"),
databaseName: z.string().default("mysql"),
})
.merge(baseDatabaseSchema),
z
.object({
type: z.literal("postgres"),
databaseName: z.string().default("postgres"),
databaseUser: z.string().default("postgres"),
})
.merge(baseDatabaseSchema),
z
.object({
type: z.literal("redis"),
})
.merge(baseDatabaseSchema),
])
.superRefine((data, ctx) => {
if (data.type === "libsql") {
if (data.sqldNode === "replica" && !data.sqldPrimaryUrl) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["sqldPrimaryUrl"],
message: "sqldPrimaryUrl is required when sqldNode is 'replica'.",
});
}
if (data.sqldNode !== "replica" && data.sqldPrimaryUrl) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["sqldPrimaryUrl"],
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
})
.optional(),
databaseUser: z.string().default("mysql"),
databaseName: z.string().default("mysql"),
})
.merge(baseDatabaseSchema),
z
.object({
type: z.literal("mariadb"),
dockerImage: z.string().default("mariadb:4"),
databaseRootPassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
})
.optional(),
databaseUser: z.string().default("mariadb"),
databaseName: z.string().default("mariadb"),
})
.merge(baseDatabaseSchema),
]);
"sqldPrimaryUrl should not be provided when sqldNode is not 'replica'.",
});
}
}
});
const databasesMap = {
postgres: {
@@ -166,6 +201,10 @@ const databasesMap = {
icon: <RedisIcon />,
label: "Redis",
},
libsql: {
icon: <LibsqlIcon className="size-10" />,
label: "libSQL",
},
};
type AddDatabase = z.infer<typeof mySchema>;
@@ -181,11 +220,12 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
const slug = slugify(projectName);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery();
const postgresMutation = api.postgres.create.useMutation();
const mongoMutation = api.mongo.create.useMutation();
const redisMutation = api.redis.create.useMutation();
const libsqlMutation = api.libsql.create.useMutation();
const mariadbMutation = api.mariadb.create.useMutation();
const mongoMutation = api.mongo.create.useMutation();
const mysqlMutation = api.mysql.create.useMutation();
const postgresMutation = api.postgres.create.useMutation();
const redisMutation = api.redis.create.useMutation();
// Get environment data to extract projectId
const { data: environment } = api.environment.one.useQuery({ environmentId });
@@ -210,13 +250,15 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
},
resolver: zodResolver(mySchema),
});
const sqldNode = form.watch("sqldNode");
const type = form.watch("type");
const activeMutation = {
postgres: postgresMutation,
mongo: mongoMutation,
redis: redisMutation,
libsql: libsqlMutation,
mariadb: mariadbMutation,
mongo: mongoMutation,
mysql: mysqlMutation,
postgres: postgresMutation,
redis: redisMutation,
};
const onSubmit = async (data: AddDatabase) => {
@@ -233,12 +275,23 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
description: data.description,
};
if (data.type === "postgres") {
promise = postgresMutation.mutateAsync({
if (data.type === "libsql") {
promise = libsqlMutation.mutateAsync({
...commonParams,
sqldNode: data.sqldNode,
sqldPrimaryUrl: data.sqldPrimaryUrl ?? null,
enableNamespaces: data.enableNamespaces,
databasePassword: data.databasePassword,
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId === "dokploy" ? null : data.serverId,
});
} else if (data.type === "mariadb") {
promise = mariadbMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
databaseName: data.databaseName || "postgres",
databaseRootPassword: data.databaseRootPassword || "",
databaseName: data.databaseName || "mariadb",
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId === "dokploy" ? null : data.serverId,
@@ -252,22 +305,6 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
serverId: data.serverId === "dokploy" ? null : data.serverId,
replicaSets: data.replicaSets,
});
} else if (data.type === "redis") {
promise = redisMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
serverId: data.serverId === "dokploy" ? null : data.serverId,
});
} else if (data.type === "mariadb") {
promise = mariadbMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
databaseRootPassword: data.databaseRootPassword || "",
databaseName: data.databaseName || "mariadb",
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId === "dokploy" ? null : data.serverId,
});
} else if (data.type === "mysql") {
promise = mysqlMutation.mutateAsync({
...commonParams,
@@ -278,6 +315,21 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
serverId: data.serverId === "dokploy" ? null : data.serverId,
databaseRootPassword: data.databaseRootPassword || "",
});
} else if (data.type === "postgres") {
promise = postgresMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
databaseName: data.databaseName || "postgres",
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId === "dokploy" ? null : data.serverId,
});
} else if (data.type === "redis") {
promise = redisMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
serverId: data.serverId === "dokploy" ? null : data.serverId,
});
}
if (promise) {
@@ -305,6 +357,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
});
}
};
return (
<Dialog open={visible} onOpenChange={setVisible}>
<DialogTrigger className="w-full">
@@ -506,8 +559,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
</FormItem>
)}
/>
{(type === "mysql" ||
type === "mariadb" ||
{(type === "mariadb" ||
type === "mysql" ||
type === "postgres") && (
<FormField
control={form.control}
@@ -524,10 +577,101 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
)}
/>
)}
{(type === "mysql" ||
{type === "libsql" && (
<FormField
control={form.control}
name="sqldNode"
render={({ field }) => (
<FormItem>
<FormLabel>Sqld Node</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || "primary"}
>
<SelectTrigger>
<SelectValue placeholder={"primary"} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{["primary", "replica"].map((node) => (
<SelectItem key={node} value={node}>
{node.charAt(0).toUpperCase() + node.slice(1)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
{type === "libsql" && sqldNode === "replica" && (
<FormField
control={form.control}
name="sqldPrimaryUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Sqld Primary URL</FormLabel>
<FormControl>
<Input
placeholder={"https://<host>:<port>"}
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{type === "libsql" && (
<FormField
control={form.control}
name="enableNamespaces"
render={({ field }) => {
console.log(field.value);
return (
<FormItem>
<FormLabel>Enable Namespaces</FormLabel>
<FormControl>
<Select
onValueChange={(value) =>
field.onChange(Boolean(value))
}
defaultValue={
field.value ? String(field.value) : "false"
}
>
<SelectTrigger>
<SelectValue placeholder={"false"} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{["false", "true"].map((node) => (
<SelectItem key={node} value={node}>
{node.charAt(0).toUpperCase() +
node.slice(1)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
)}
{(type === "libsql" ||
type === "mariadb" ||
type === "postgres" ||
type === "mongo") && (
type === "mongo" ||
type === "mysql" ||
type === "postgres") && (
<FormField
control={form.control}
name="databaseUser"
@@ -568,7 +712,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
</FormItem>
)}
/>
{(type === "mysql" || type === "mariadb") && (
{(type === "mariadb" || type === "mysql") && (
<FormField
control={form.control}
name="databaseRootPassword"
@@ -1,5 +1,6 @@
import {
BookText,
Bookmark,
CheckIcon,
ChevronsUpDown,
Globe,
@@ -82,6 +83,7 @@ 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;
@@ -122,8 +124,45 @@ 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();
@@ -137,7 +176,9 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
query === "" ||
template.name.toLowerCase().includes(query.toLowerCase()) ||
template.description.toLowerCase().includes(query.toLowerCase());
return matchesTags && matchesQuery;
const matchesBookmarks =
!showBookmarksOnly || bookmarkIds.includes(template.id);
return matchesTags && matchesQuery && matchesBookmarks;
}) || [];
const hasServers = servers && servers.length > 0;
@@ -146,6 +187,14 @@ 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">
@@ -243,6 +292,20 @@ 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={() =>
@@ -299,11 +362,19 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
</div>
</div>
) : templates.length === 0 ? (
<div className="flex justify-center items-center w-full gap-2 min-h-[50vh]">
<div className="flex flex-col 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">
No templates found
{showBookmarksOnly
? "No bookmarked 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
@@ -323,15 +394,32 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
viewMode === "detailed" && "h-[400px]",
)}
>
<Badge className="absolute top-2 right-2" variant="blue">
{template?.version}
</Badge>
<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>
<div
className={cn(
"flex-none p-6 pb-3 flex flex-col items-center gap-4 bg-muted/30",
viewMode === "detailed" && "border-b",
)}
>
{/** biome-ignore lint/performance/noImgElement: this is a valid use for img tag */}
<img
src={`${customBaseUrl || "https://templates.dokploy.com/"}/blueprints/${template?.id}/${template?.logo}`}
className={cn(
@@ -92,6 +92,8 @@ export const AdvancedEnvironmentSelector = ({
toast.success("Environment created successfully");
utils.environment.byProjectId.invalidate({ projectId });
// Invalidate the project query to refresh the project data for the advance-breadcrumb
utils.project.all.invalidate();
setIsCreateDialogOpen(false);
setName("");
setDescription("");
@@ -298,7 +298,19 @@ 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={stepper.prev}
onClick={() => {
if (
stepper.current.id === "variant" &&
templateInfo.details
) {
setTemplateInfo((prev) => ({
...prev,
details: null,
}));
return;
}
stepper.prev();
}}
disabled={stepper.isFirst}
variant="secondary"
>
@@ -28,13 +28,14 @@ export type Services = {
serverId?: string | null;
name: string;
type:
| "mariadb"
| "application"
| "postgres"
| "mysql"
| "compose"
| "libsql"
| "mariadb"
| "mongo"
| "redis"
| "compose";
| "mysql"
| "postgres"
| "redis";
description?: string | null;
id: string;
createdAt: string;
@@ -88,7 +88,12 @@ 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.key === "s" && !isPending && isOpen) {
if (
(e.ctrlKey || e.metaKey) &&
e.code === "KeyS" &&
!isPending &&
isOpen
) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
@@ -7,6 +7,7 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { TagSelector } from "@/components/shared/tag-selector";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -62,6 +63,7 @@ interface Props {
export const HandleProject = ({ projectId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const { mutateAsync, error, isError } = projectId
? api.project.update.useMutation()
@@ -75,6 +77,10 @@ export const HandleProject = ({ projectId }: Props) => {
enabled: !!projectId,
},
);
const { data: availableTags = [] } = api.tag.all.useQuery();
const bulkAssignMutation = api.tag.bulkAssign.useMutation();
const router = useRouter();
const form = useForm<AddProject>({
defaultValues: {
@@ -89,6 +95,13 @@ export const HandleProject = ({ projectId }: Props) => {
description: data?.description ?? "",
name: data?.name ?? "",
});
// Load existing tags when editing a project
if (data?.projectTags) {
const tagIds = data.projectTags.map((pt) => pt.tagId);
setSelectedTagIds(tagIds);
} else {
setSelectedTagIds([]);
}
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
const onSubmit = async (data: AddProject) => {
@@ -98,12 +111,26 @@ export const HandleProject = ({ projectId }: Props) => {
projectId: projectId || "",
})
.then(async (data) => {
// Assign tags to the project (both create and update)
const projectIdToUse =
projectId ||
(data && "project" in data ? data.project.projectId : undefined);
if (projectIdToUse) {
try {
await bulkAssignMutation.mutateAsync({
projectId: projectIdToUse,
tagIds: selectedTagIds,
});
} catch (error) {
toast.error("Failed to assign tags to project");
}
}
await utils.project.all.invalidate();
toast.success(projectId ? "Project Updated" : "Project Created");
setIsOpen(false);
if (!projectId) {
const projectIdToUse =
data && "project" in data ? data.project.projectId : undefined;
const environmentIdToUse =
data && "environment" in data
? data.environment.environmentId
@@ -190,6 +217,20 @@ export const HandleProject = ({ projectId }: Props) => {
</FormItem>
)}
/>
<div className="space-y-2">
<FormLabel>Tags</FormLabel>
<TagSelector
tags={availableTags.map((tag) => ({
id: tag.tagId,
name: tag.name,
color: tag.color ?? undefined,
}))}
selectedTags={selectedTagIds}
onTagsChange={setSelectedTagIds}
placeholder="Select tags..."
/>
</div>
</form>
<DialogFooter>
@@ -87,7 +87,12 @@ 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.key === "s" && !isPending && isOpen) {
if (
(e.ctrlKey || e.metaKey) &&
e.code === "KeyS" &&
!isPending &&
isOpen
) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
@@ -15,6 +15,8 @@ import { toast } from "sonner";
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
import { TagBadge } from "@/components/shared/tag-badge";
import { TagFilter } from "@/components/shared/tag-filter";
import {
AlertDialog,
AlertDialogAction,
@@ -49,7 +51,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TimeBadge } from "@/components/ui/time-badge";
import { api } from "@/utils/api";
import { useDebounce } from "@/utils/hooks/use-debounce";
import { HandleProject } from "./handle-project";
@@ -63,6 +64,7 @@ export const ShowProjects = () => {
const { data: auth } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { mutateAsync } = api.project.remove.useMutation();
const { data: availableTags } = api.tag.all.useQuery();
const [searchQuery, setSearchQuery] = useState(
router.isReady && typeof router.query.q === "string" ? router.query.q : "",
@@ -76,10 +78,31 @@ export const ShowProjects = () => {
return "createdAt-desc";
});
const [selectedTagIds, setSelectedTagIds] = useState<string[]>(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("projectsTagFilter");
return saved ? JSON.parse(saved) : [];
}
return [];
});
useEffect(() => {
localStorage.setItem("projectsSort", sortBy);
}, [sortBy]);
useEffect(() => {
localStorage.setItem("projectsTagFilter", JSON.stringify(selectedTagIds));
}, [selectedTagIds]);
useEffect(() => {
if (!availableTags) return;
const validIds = new Set(availableTags.map((t) => t.tagId));
setSelectedTagIds((prev) => {
const filtered = prev.filter((id) => validIds.has(id));
return filtered.length === prev.length ? prev : filtered;
});
}, [availableTags]);
useEffect(() => {
if (!router.isReady) return;
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
@@ -107,7 +130,7 @@ export const ShowProjects = () => {
const filteredProjects = useMemo(() => {
if (!data) return [];
const filtered = data.filter(
let filtered = data.filter(
(project) =>
project.name
.toLowerCase()
@@ -117,6 +140,15 @@ export const ShowProjects = () => {
.includes(debouncedSearchQuery.toLowerCase()),
);
// Filter by selected tags (OR logic: show projects with ANY selected tag)
if (selectedTagIds.length > 0) {
filtered = filtered.filter((project) =>
project.projectTags?.some((pt) =>
selectedTagIds.includes(pt.tag.tagId),
),
);
}
// Then sort the filtered results
const [field, direction] = sortBy.split("-");
return [...filtered].sort((a, b) => {
@@ -162,7 +194,7 @@ export const ShowProjects = () => {
}
return direction === "asc" ? comparison : -comparison;
});
}, [data, debouncedSearchQuery, sortBy]);
}, [data, debouncedSearchQuery, sortBy, selectedTagIds]);
return (
<>
@@ -208,29 +240,44 @@ export const ShowProjects = () => {
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
</div>
<div className="flex items-center gap-2 min-w-48 max-sm:w-full">
<ArrowUpDown className="size-4 text-muted-foreground" />
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Sort by..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
<SelectItem value="name-desc">Name (Z-A)</SelectItem>
<SelectItem value="createdAt-desc">
Newest first
</SelectItem>
<SelectItem value="createdAt-asc">
Oldest first
</SelectItem>
<SelectItem value="services-desc">
Most services
</SelectItem>
<SelectItem value="services-asc">
Least services
</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<TagFilter
tags={
availableTags?.map((tag) => ({
id: tag.tagId,
name: tag.name,
color: tag.color || undefined,
})) || []
}
selectedTags={selectedTagIds}
onTagsChange={setSelectedTagIds}
/>
<div className="flex items-center gap-2 min-w-48 max-sm:w-full">
<ArrowUpDown className="size-4 text-muted-foreground" />
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Sort by..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
<SelectItem value="name-desc">
Name (Z-A)
</SelectItem>
<SelectItem value="createdAt-desc">
Newest first
</SelectItem>
<SelectItem value="createdAt-asc">
Oldest first
</SelectItem>
<SelectItem value="services-desc">
Most services
</SelectItem>
<SelectItem value="services-asc">
Least services
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{filteredProjects?.length === 0 && (
@@ -247,26 +294,27 @@ export const ShowProjects = () => {
.map(
(env) =>
env.applications.length === 0 &&
env.compose.length === 0 &&
env.libsql.length === 0 &&
env.mariadb.length === 0 &&
env.mongo.length === 0 &&
env.mysql.length === 0 &&
env.postgres.length === 0 &&
env.redis.length === 0 &&
env.applications.length === 0 &&
env.compose.length === 0,
env.redis.length === 0,
)
.every(Boolean);
const totalServices = project?.environments
.map(
(env) =>
env.applications.length +
env.compose.length +
env.libsql.length +
env.mariadb.length +
env.mongo.length +
env.mysql.length +
env.postgres.length +
env.redis.length +
env.applications.length +
env.compose.length,
env.redis.length,
)
.reduce((acc, curr) => acc + curr, 0);
@@ -309,6 +357,19 @@ export const ShowProjects = () => {
{project.description}
</span>
{project.projectTags &&
project.projectTags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{project.projectTags.map((pt) => (
<TagBadge
key={pt.tag.tagId}
name={pt.tag.name}
color={pt.tag.color}
/>
))}
</div>
)}
{hasNoEnvironments && (
<div className="flex flex-row gap-2 items-center rounded-lg bg-yellow-50 p-2 mt-2 dark:bg-yellow-950">
<AlertTriangle className="size-4 text-yellow-600 dark:text-yellow-400 shrink-0" />
@@ -429,7 +490,7 @@ export const ShowProjects = () => {
</CardTitle>
</CardHeader>
<CardFooter className="pt-4">
<div className="space-y-1 text-sm flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4">
<div className="space-y-1 text-xs flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4">
<DateTooltip date={project.createdAt}>
Created
</DateTooltip>
@@ -1,14 +1,19 @@
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 ">
@@ -24,11 +29,21 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
</div>
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<div className="flex flex-row gap-2 items-center">
<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.key === "j" && (e.metaKey || e.ctrlKey)) {
if (e.code === "KeyJ" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((open) => !open);
}
@@ -2,12 +2,14 @@ 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";
@@ -24,7 +26,17 @@ 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";
@@ -89,6 +101,8 @@ 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);
@@ -141,6 +155,7 @@ 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);
@@ -149,14 +164,66 @@ 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>
<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 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>
<CardContent className="space-y-4 py-4 border-t">
<nav className="flex space-x-2 border-b">
@@ -182,7 +249,7 @@ export const ShowBilling = () => {
</nav>
<div className="flex flex-col gap-4 w-full mt-6">
{admin?.user.stripeSubscriptionId && (
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
<div className="space-y-2 flex flex-col">
<h3 className="text-lg font-medium">Servers Plan</h3>
<p className="text-sm text-muted-foreground">
@@ -203,8 +270,36 @@ 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 */}
{useNewPricing &&
{!isEnterpriseCloud &&
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">
@@ -394,7 +489,8 @@ export const ShowBilling = () => {
</div>
)}
{/* Cambiar plan o cantidad de servidores (usuarios en Hobby o Startup; el portal no permite esto) */}
{useNewPricing &&
{!isEnterpriseCloud &&
useNewPricing &&
(data?.currentPlan === "hobby" ||
data?.currentPlan === "startup") &&
data?.subscriptions?.length > 0 && (
@@ -779,17 +875,18 @@ export const ShowBilling = () => {
Manage Subscription
</Button>
)}
{(data?.subscriptions?.length ?? 0) === 0 && (
<Button
className="w-full"
onClick={() =>
handleCheckout("hobby", data!.hobbyProductId!)
}
disabled={hobbyServerQuantity < 1}
>
Get Started
</Button>
)}
{!isEnterpriseCloud &&
(data?.subscriptions?.length ?? 0) === 0 && (
<Button
className="w-full"
onClick={() =>
handleCheckout("hobby", data!.hobbyProductId!)
}
disabled={hobbyServerQuantity < 1}
>
Get Started
</Button>
)}
</div>
</div>
</section>
@@ -923,22 +1020,24 @@ export const ShowBilling = () => {
Manage Subscription
</Button>
)}
{(data?.subscriptions?.length ?? 0) === 0 && (
<Button
className="w-full"
onClick={() =>
handleCheckout(
"startup",
data!.startupProductId!,
)
}
disabled={
startupServerQuantity < STARTUP_SERVERS_INCLUDED
}
>
Get Started
</Button>
)}
{!isEnterpriseCloud &&
(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>
@@ -1143,17 +1242,18 @@ export const ShowBilling = () => {
Manage Subscription
</Button>
)}
{(data?.subscriptions?.length ?? 0) === 0 && (
<Button
className="w-full"
onClick={async () => {
handleCheckout("legacy", product.id);
}}
disabled={hobbyServerQuantity < 1}
>
Subscribe
</Button>
)}
{!isEnterpriseCloud &&
(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 } from "lucide-react";
import { HelpCircle, PlusIcon, SquarePen } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -47,108 +47,157 @@ const certificateDataHolder =
const privateKeyDataHolder =
"-----BEGIN PRIVATE KEY-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n-----END PRIVATE KEY-----";
const addCertificate = z.object({
const handleCertificateSchema = 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 AddCertificate = z.infer<typeof addCertificate>;
type HandleCertificateForm = z.infer<typeof handleCertificateSchema>;
export const AddCertificate = () => {
interface Props {
certificateId?: string;
}
export const HandleCertificate = ({ certificateId }: Props) => {
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;
// 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 shouldShowServerDropdown = hasServers && !certificateId; // Hide on edit
const form = useForm<AddCertificate>({
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>({
defaultValues: {
name: "",
certificateData: "",
privateKey: "",
autoRenew: false,
},
resolver: zodResolver(addCertificate),
resolver: zodResolver(handleCertificateSchema),
});
useEffect(() => {
form.reset();
}, [form, form.formState.isSubmitSuccessful, form.reset]);
const onSubmit = async (data: AddCertificate) => {
await mutateAsync({
useEffect(() => {
if (existingCert) {
form.reset({
name: existingCert.name,
certificateData: existingCert.certificateData,
privateKey: existingCert.privateKey,
});
} else {
form.reset({
name: "",
certificateData: "",
privateKey: "",
});
}
}, [existingCert, form, open]);
const onSubmit = async (data: HandleCertificateForm) => {
const basePayload = {
name: data.name,
certificateData: data.certificateData,
privateKey: data.privateKey,
autoRenew: data.autoRenew,
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
organizationId: "",
})
};
const promise = certificateId
? updateMutation.mutateAsync({
certificateId,
...basePayload,
})
: createMutation.mutateAsync({
...basePayload,
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
organizationId: "",
});
await promise
.then(async () => {
toast.success("Certificate Created");
toast.success(
certificateId ? "Certificate Updated" : "Certificate Created",
);
await utils.certificates.all.invalidate();
if (certificateId) {
refetch();
}
setOpen(false);
})
.catch(() => {
toast.error("Error creating the Certificate");
toast.error(
certificateId
? "Error updating the Certificate"
: "Error creating the Certificate",
);
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="" asChild>
<Button>
{" "}
<PlusIcon className="h-4 w-4" />
Add Certificate
</Button>
<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>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Add New Certificate</DialogTitle>
<DialogTitle>
{certificateId ? "Update" : "Add New"} Certificate
</DialogTitle>
<DialogDescription>
Upload or generate a certificate to secure your application
{certificateId
? "Modify the certificate details"
: "Upload or generate a certificate to secure your application"}
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-add-certificate"
id="hook-form-handle-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 }) => {
return (
<FormItem>
<FormLabel>Certificate Name</FormLabel>
<FormControl>
<Input placeholder={"My Certificate"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
render={({ field }) => (
<FormItem>
<FormLabel>Certificate Name</FormLabel>
<FormControl>
<Input placeholder="My Certificate" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="certificateData"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Certificate Data</FormLabel>
</div>
<FormLabel>Certificate Data</FormLabel>
<FormControl>
<Textarea
className="h-32"
@@ -165,9 +214,7 @@ export const AddCertificate = () => {
name="privateKey"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Private Key</FormLabel>
</div>
<FormLabel>Private Key</FormLabel>
<FormControl>
<Textarea
className="h-32"
@@ -248,10 +295,10 @@ export const AddCertificate = () => {
<DialogFooter className="flex w-full flex-row !justify-end">
<Button
isLoading={isPending}
form="hook-form-add-certificate"
form="hook-form-handle-certificate"
type="submit"
>
Create
{certificateId ? "Update" : "Create"}
</Button>
</DialogFooter>
</Form>
@@ -1,4 +1,14 @@
import { AlertCircle, Link, Loader2, ShieldCheck, Trash2 } from "lucide-react";
import {
AlertCircle,
ChevronDown,
ChevronRight,
Link,
Loader2,
Server,
ShieldCheck,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -11,14 +21,20 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { AddCertificate } from "./add-certificate";
import { getCertificateChainInfo, getExpirationStatus } from "./utils";
import { HandleCertificate } from "./handle-certificate";
import {
extractLeafCommonName,
getCertificateChainExpirationDetails,
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">
@@ -54,7 +70,7 @@ export const ShowCertificates = () => {
<span className="text-base text-muted-foreground text-center">
You don't have any certificates created
</span>
{permissions?.certificate.create && <AddCertificate />}
{permissions?.certificate.create && <HandleCertificate />}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
@@ -66,6 +82,30 @@ 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}
@@ -77,12 +117,58 @@ 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 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 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>
)}
<div
@@ -102,8 +188,14 @@ export const ShowCertificates = () => {
</div>
</div>
{permissions?.certificate.delete && (
<div className="flex flex-row gap-1">
<div className="flex flex-row gap-1">
{permissions?.certificate.update && (
<HandleCertificate
certificateId={certificate.certificateId}
/>
)}
{permissions?.certificate.delete && (
<DialogAction
title="Delete Certificate"
description="Are you sure you want to delete this certificate?"
@@ -129,14 +221,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>
);
@@ -145,7 +237,7 @@ export const ShowCertificates = () => {
{permissions?.certificate.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<AddCertificate />
<HandleCertificate />
</div>
)}
</div>
@@ -1,5 +1,13 @@
// @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
@@ -14,13 +22,13 @@ export const extractExpirationDate = (certData: string): Date | null => {
// Helper: read ASN.1 length field
function readLength(pos: number): { length: number; offset: number } {
// biome-ignore lint/style/noParameterAssign: <explanation>
// biome-ignore lint/style/noParameterAssign: this is for dynamic length calculation
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>
// biome-ignore lint/style/noParameterAssign: this is for dynamic length calculation
len = (len << 8) + der[pos++];
}
}
@@ -28,11 +36,11 @@ export const extractExpirationDate = (certData: string): Date | null => {
}
// Skip the outer certificate sequence
if (der[offset++] !== 0x30) throw new Error("Expected sequence");
if (der[offset++] !== 0x30) return null;
({ offset } = readLength(offset));
// Skip tbsCertificate sequence
if (der[offset++] !== 0x30) throw new Error("Expected tbsCertificate");
if (der[offset++] !== 0x30) return null;
({ offset } = readLength(offset));
// Check for optional version field (context-specific tag [0])
@@ -44,15 +52,14 @@ 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)
throw new Error("Unexpected structure");
if (der[offset] !== 0x30 && der[offset] !== 0x02) return null;
offset++;
const fieldLen = readLength(offset);
offset = fieldLen.offset + fieldLen.length;
}
// Validity sequence (notBefore and notAfter)
if (der[offset++] !== 0x30) throw new Error("Expected validity sequence");
if (der[offset++] !== 0x30) return null;
const validityLen = readLength(offset);
offset = validityLen.offset;
@@ -94,8 +101,156 @@ 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 expirationDate = extractExpirationDate(certData);
const chainInfo = getCertificateChainInfo(certData);
const expirationDate = chainInfo.isChain
? getEarliestExpirationDate(certData)
: extractExpirationDate(certData);
if (!expirationDate)
return {
@@ -153,3 +308,67 @@ 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,11 @@
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 } from "lucide-react";
import { PenBoxIcon, PlusIcon, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
@@ -46,6 +50,16 @@ const addDestination = z.object({
region: z.string(),
endpoint: z.string().min(1, "Endpoint is required"),
serverId: z.string().optional(),
additionalFlags: z
.array(
z.object({
value: z
.string()
.min(1, "Flag cannot be empty")
.regex(ADDITIONAL_FLAG_REGEX, ADDITIONAL_FLAG_ERROR),
}),
)
.optional(),
});
type AddDestination = z.infer<typeof addDestination>;
@@ -89,9 +103,16 @@ export const HandleDestinations = ({ destinationId }: Props) => {
region: "",
secretAccessKey: "",
endpoint: "",
additionalFlags: [],
},
resolver: zodResolver(addDestination),
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "additionalFlags",
});
useEffect(() => {
if (destination) {
form.reset({
@@ -102,6 +123,8 @@ export const HandleDestinations = ({ destinationId }: Props) => {
bucket: destination.bucket,
region: destination.region,
endpoint: destination.endpoint,
additionalFlags:
destination.additionalFlags?.map((f) => ({ value: f })) ?? [],
});
} else {
form.reset();
@@ -118,6 +141,7 @@ export const HandleDestinations = ({ destinationId }: Props) => {
region: data.region,
secretAccessKey: data.secretAccessKey,
destinationId: destinationId || "",
additionalFlags: data.additionalFlags?.map((f) => f.value) ?? [],
})
.then(async () => {
toast.success(`Destination ${destinationId ? "Updated" : "Created"}`);
@@ -127,9 +151,12 @@ export const HandleDestinations = ({ destinationId }: Props) => {
}
setOpen(false);
})
.catch(() => {
.catch((e) => {
toast.error(
`Error ${destinationId ? "Updating" : "Creating"} the Destination`,
{
description: e.message,
},
);
});
};
@@ -141,6 +168,7 @@ export const HandleDestinations = ({ destinationId }: Props) => {
"secretAccessKey",
"bucket",
"endpoint",
"additionalFlags",
]);
if (!result) {
@@ -179,6 +207,8 @@ export const HandleDestinations = ({ destinationId }: Props) => {
region,
secretAccessKey: secretKey,
serverId,
additionalFlags:
form.getValues("additionalFlags")?.map((f) => f.value) ?? [],
})
.then(() => {
toast.success("Connection Success");
@@ -358,6 +388,48 @@ export const HandleDestinations = ({ destinationId }: Props) => {
</FormItem>
)}
/>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<FormLabel>Additional Flags (Optional)</FormLabel>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => append({ value: "" })}
>
<PlusIcon className="size-4" />
Add Flag
</Button>
</div>
{fields.map((field, index) => (
<FormField
key={field.id}
control={form.control}
name={`additionalFlags.${index}.value`}
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormControl>
<Input
placeholder="--s3-sign-accept-encoding=false"
{...field}
/>
</FormControl>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(index)}
>
<Trash2 className="size-4 text-muted-foreground" />
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
</form>
<DialogFooter

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