Merge branch 'canary' into feat/add-network-management

This commit is contained in:
Mauricio Siu
2026-04-13 15:59:23 -06:00
582 changed files with 244981 additions and 32060 deletions

View File

@@ -13,6 +13,17 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Set tag and version
id: meta-cloud
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
echo "version=$VERSION" >> $GITHUB_OUTPUT
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> $GITHUB_OUTPUT
else
echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
@@ -25,8 +36,7 @@ jobs:
context: . context: .
file: ./Dockerfile.cloud file: ./Dockerfile.cloud
push: true push: true
tags: | tags: ${{ steps.meta-cloud.outputs.tags }}
siumauricio/cloud:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
platforms: linux/amd64 platforms: linux/amd64
build-args: | build-args: |
NEXT_PUBLIC_UMAMI_HOST=${{ secrets.NEXT_PUBLIC_UMAMI_HOST }} NEXT_PUBLIC_UMAMI_HOST=${{ secrets.NEXT_PUBLIC_UMAMI_HOST }}
@@ -40,6 +50,16 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Set tag and version
id: meta-schedule
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> $GITHUB_OUTPUT
else
echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
@@ -52,8 +72,7 @@ jobs:
context: . context: .
file: ./Dockerfile.schedule file: ./Dockerfile.schedule
push: true push: true
tags: | tags: ${{ steps.meta-schedule.outputs.tags }}
siumauricio/schedule:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
platforms: linux/amd64 platforms: linux/amd64
build-and-push-server-image: build-and-push-server-image:
@@ -63,6 +82,16 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Set tag and version
id: meta-server
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> $GITHUB_OUTPUT
else
echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
@@ -75,6 +104,5 @@ jobs:
context: . context: .
file: ./Dockerfile.server file: ./Dockerfile.server
push: true push: true
tags: | tags: ${{ steps.meta-server.outputs.tags }}
siumauricio/server:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
platforms: linux/amd64 platforms: linux/amd64

View File

@@ -16,7 +16,6 @@ jobs:
steps: steps:
- uses: peakoss/anti-slop@v0 - uses: peakoss/anti-slop@v0
with: with:
max-failures: 4
blocked-commit-authors: "claude,copilot" blocked-commit-authors: "claude,copilot"
require-description: true require-description: true
min-account-age: 5 min-account-age: 5

View File

@@ -99,7 +99,14 @@ pnpm run dokploy:build
## Docker ## 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 ```bash
pnpm run docker:build pnpm run docker:build
@@ -165,10 +172,11 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.
### Important Considerations for Pull Requests ### Important Considerations for Pull Requests
- **Testing is Mandatory:** All Pull Requests **must be tested** before submission. You must verify that your changes work as expected in a local development environment (see [Setup](#setup)). **Pull Requests that have not been tested will be closed.** This policy ensures clean contributions and reduces the time maintainers spend reviewing untested or broken code. - **Testing is Mandatory:** All Pull Requests **must be tested** by the PR author before submission. You must verify that your changes work as expected in a local development environment (see [Setup](#setup)). **Pull Requests that have not been tested by their creator will be rejected.** This policy keeps the PR history clean and values contributors who submit verified, working code. Untested PRs are often recognizable by disproportionately large or scattered changes for simple tasks—please test first.
- **Focus and Scope:** Each Pull Request should ideally address a single, well-defined problem or introduce one new feature. This greatly facilitates review and reduces the chances of introducing unintended side effects. - **Focus and Scope:** Each Pull Request should ideally address a single, well-defined problem or introduce one new feature. This greatly facilitates review and reduces the chances of introducing unintended side effects.
- **Avoid Unfocused Changes:** Please avoid submitting Pull Requests that contain only minor changes such as whitespace adjustments, IDE-generated formatting, or removal of unused variables, unless these are part of a larger, clearly defined refactor or a dedicated "cleanup" Pull Request that addresses a specific `good first issue` or maintenance task. - **Avoid Unfocused Changes:** Please avoid submitting Pull Requests that contain only minor changes such as whitespace adjustments, IDE-generated formatting, or removal of unused variables, unless these are part of a larger, clearly defined refactor or a dedicated "cleanup" Pull Request that addresses a specific `good first issue` or maintenance task.
- **Issue Association:** For any significant change, it's highly recommended to open an issue first to discuss the proposed solution with the community and maintainers. This ensures alignment and avoids duplicated effort. If your PR resolves an existing issue, please link it in the description (e.g., `Fixes #123`, `Closes #456`). - **Issue Association:** For any significant change, it's highly recommended to open an issue first to discuss the proposed solution with the community and maintainers. This ensures alignment and avoids duplicated effort. If your PR resolves an existing issue, please link it in the description (e.g., `Fixes #123`, `Closes #456`).
- **Large Features:** Pull Requests that introduce very large or broad features **will not be accepted** unless the idea is first outlined and discussed in a GitHub issue. Large features should be designed together with the Dokploy team so the project stays coherent and moves in the same direction. Open an issue to propose and align on the design before implementing.
Thank you for your contribution! Thank you for your contribution!

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. Dokploy includes multiple features to make your life easier.
- **Applications**: Deploy any type of application (Node.js, PHP, Python, Go, Ruby, etc.). - **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. - **Backups**: Automate backups for databases to an external storage destination.
- **Docker Compose**: Native support for Docker Compose to manage complex applications. - **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. - **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). Want to skip the installation process? [Try the Dokploy Cloud](https://app.dokploy.com).
```bash ```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). For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).

View File

@@ -1,2 +1,11 @@
LEMON_SQUEEZY_API_KEY="" LEMON_SQUEEZY_API_KEY=""
LEMON_SQUEEZY_STORE_ID="" LEMON_SQUEEZY_STORE_ID=""
# Inngest (for GET /jobs - list deployment queue). Self-hosted example:
# INNGEST_BASE_URL="http://localhost:8288"
# Production: INNGEST_BASE_URL="https://dev-inngest.dokploy.com"
# INNGEST_SIGNING_KEY="your-signing-key"
# Optional: only events after this RFC3339 timestamp. If unset, no date filter is applied.
# INNGEST_EVENTS_RECEIVED_AFTER="2024-01-01T00:00:00Z"
# Max events to fetch when listing jobs (paginates with cursor). Default 100, max 10000.
# INNGEST_JOBS_MAX_EVENTS=100

View File

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

View File

@@ -10,6 +10,7 @@ import {
type DeployJob, type DeployJob,
deployJobSchema, deployJobSchema,
} from "./schema.js"; } from "./schema.js";
import { fetchDeploymentJobs } from "./service.js";
import { deploy } from "./utils.js"; import { deploy } from "./utils.js";
const app = new Hono(); const app = new Hono();
@@ -118,7 +119,6 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => {
200, 200,
); );
} catch (error) { } catch (error) {
console.log("error", error);
logger.error("Failed to send deployment event", error); logger.error("Failed to send deployment event", error);
return c.json( return c.json(
{ {
@@ -176,6 +176,29 @@ app.get("/health", async (c) => {
return c.json({ status: "ok" }); return c.json({ status: "ok" });
}); });
// List deployment jobs (Inngest runs) for a server - same shape as BullMQ queue for the UI
app.get("/jobs", async (c) => {
const serverId = c.req.query("serverId");
if (!serverId) {
return c.json({ message: "serverId is required" }, 400);
}
try {
const rows = await fetchDeploymentJobs(serverId);
return c.json(rows);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("INNGEST_BASE_URL")) {
return c.json(
{ message: "INNGEST_BASE_URL is required to list deployment jobs" },
503,
);
}
logger.error("Failed to fetch jobs from Inngest", { serverId, error });
return c.json([], 200);
}
});
// Serve Inngest functions endpoint // Serve Inngest functions endpoint
app.on( app.on(
["GET", "POST", "PUT"], ["GET", "POST", "PUT"],

239
apps/api/src/service.ts Normal file
View File

@@ -0,0 +1,239 @@
import { logger } from "./logger.js";
const baseUrl = process.env.INNGEST_BASE_URL ?? "";
const signingKey = process.env.INNGEST_SIGNING_KEY ?? "";
const DEFAULT_MAX_EVENTS = 500;
const MAX_EVENTS = DEFAULT_MAX_EVENTS;
/** Event shape from GET /v1/events (https://api.inngest.com/v1/events) */
type InngestEventRow = {
internal_id?: string;
accountID?: string;
environmentID?: string;
source?: string;
sourceID?: string | null;
/** RFC3339 timestamp API uses receivedAt, dev server may use received_at */
receivedAt?: string;
received_at?: string;
id: string;
name: string;
data: Record<string, unknown>;
user?: unknown;
ts: number;
v?: string | null;
metadata?: {
fetchedAt: string;
cachedUntil: string | null;
};
};
/** Run shape from GET /v1/events/{eventId}/runs the actual job execution */
type InngestRun = {
run_id: string;
event_id: string;
status: string; // "Running" | "Completed" | "Failed" | "Cancelled" | "Queued"?
run_started_at?: string;
ended_at?: string | null;
output?: unknown;
// dev server / API may use different casing
run_started_at_ms?: number;
};
function getEventReceivedAt(ev: InngestEventRow): string | undefined {
return ev.receivedAt ?? ev.received_at;
}
/** Map Inngest run status to BullMQ-style state for the UI */
function runStatusToState(
status: string,
): "pending" | "active" | "completed" | "failed" | "cancelled" {
const s = status.toLowerCase();
if (s === "running") return "active";
if (s === "completed") return "completed";
if (s === "failed") return "failed";
if (s === "cancelled") return "cancelled";
if (s === "queued") return "pending";
return "pending";
}
export const fetchInngestEvents = async () => {
const maxEvents = MAX_EVENTS;
const all: InngestEventRow[] = [];
let cursor: string | undefined;
do {
const params = new URLSearchParams({ limit: "100" });
if (cursor) {
params.set("cursor", cursor);
}
const res = await fetch(`${baseUrl}/v1/events?${params}`, {
headers: {
Authorization: `Bearer ${signingKey}`,
"Content-Type": "application/json",
},
});
if (!res.ok) {
logger.warn("Inngest API error", {
status: res.status,
body: await res.text(),
});
break;
}
const body = (await res.json()) as {
data?: InngestEventRow[];
cursor?: string;
nextCursor?: string;
};
const data = Array.isArray(body.data) ? body.data : [];
all.push(...data);
// Next page: API may return cursor/nextCursor, or use last event's internal_id (per API docs)
const nextCursor =
body.cursor ?? body.nextCursor ?? data[data.length - 1]?.internal_id;
const hasMore = data.length === 100 && nextCursor && all.length < maxEvents;
cursor = hasMore ? nextCursor : undefined;
} while (cursor);
return all.slice(0, maxEvents);
};
/** Fetch runs for a single event (GET /v1/events/{eventId}/runs) runs are the actual jobs */
export const fetchInngestRunsForEvent = async (
eventId: string,
): Promise<InngestRun[]> => {
const res = await fetch(
`${baseUrl}/v1/events/${encodeURIComponent(eventId)}/runs`,
{
headers: {
Authorization: `Bearer ${signingKey}`,
"Content-Type": "application/json",
},
},
);
if (!res.ok) {
logger.warn("Inngest runs API error", {
eventId,
status: res.status,
body: await res.text(),
});
return [];
}
const body = (await res.json()) as { data?: InngestRun[] };
return Array.isArray(body.data) ? body.data : [];
};
/** One row for the queue UI (BullMQ-compatible shape) */
export type DeploymentJobRow = {
id: string;
name: string;
data: Record<string, unknown>;
timestamp: number;
processedOn?: number;
finishedOn?: number;
failedReason?: string;
state: string;
};
/** Build queue rows from events + their runs (one row per run, or pending if no run yet) */
function buildDeploymentRowsFromRuns(
events: InngestEventRow[],
runsByEventId: Map<string, InngestRun[]>,
serverId: string,
): DeploymentJobRow[] {
const requested = events.filter(
(e) =>
e.name === "deployment/requested" &&
(e.data as Record<string, unknown>)?.serverId === serverId,
);
const rows: DeploymentJobRow[] = [];
for (const ev of requested) {
const data = (ev.data ?? {}) as Record<string, unknown>;
const runs = runsByEventId.get(ev.id) ?? [];
if (runs.length === 0) {
// Queued: event received but no run yet
rows.push({
id: ev.id,
name: ev.name,
data,
timestamp: ev.ts,
processedOn: ev.ts,
finishedOn: undefined,
failedReason: undefined,
state: "pending",
});
continue;
}
for (const run of runs) {
const state = runStatusToState(run.status);
const runStartedMs =
run.run_started_at_ms ??
(run.run_started_at ? new Date(run.run_started_at).getTime() : ev.ts);
const endedMs = run.ended_at
? new Date(run.ended_at).getTime()
: undefined;
const failedReason =
state === "failed" &&
run.output &&
typeof run.output === "object" &&
"error" in run.output
? String((run.output as { error?: unknown }).error)
: undefined;
rows.push({
id: run.run_id,
name: ev.name,
data,
timestamp: runStartedMs,
processedOn: runStartedMs,
finishedOn:
state === "completed" || state === "failed" || state === "cancelled"
? endedMs
: undefined,
failedReason,
state,
});
}
}
return rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
}
/** Fetch deployment jobs for a server: events → runs → rows (correct model: runs = jobs) */
export const fetchDeploymentJobs = async (
serverId: string,
): Promise<DeploymentJobRow[]> => {
if (!signingKey) {
logger.warn("INNGEST_SIGNING_KEY not set, returning empty jobs list");
return [];
}
if (!baseUrl) {
throw new Error("INNGEST_BASE_URL is required to list deployment jobs");
}
const events = await fetchInngestEvents();
const requestedForServer = events.filter(
(e) =>
e.name === "deployment/requested" &&
(e.data as Record<string, unknown>)?.serverId === serverId,
);
// Limit to avoid too many run fetches
const toFetch = requestedForServer.slice(0, 50);
const runsByEventId = new Map<string, InngestRun[]>();
await Promise.all(
toFetch.map(async (ev) => {
const runs = await fetchInngestRunsForEvent(ev.id);
runsByEventId.set(ev.id, runs);
}),
);
return buildDeploymentRowsFromRuns(toFetch, runsByEventId, serverId);
};

View File

@@ -9,7 +9,7 @@ import {
updateCompose, updateCompose,
updatePreviewDeployment, updatePreviewDeployment,
} from "@dokploy/server"; } from "@dokploy/server";
import type { DeployJob } from "./schema"; import type { DeployJob } from "./schema.js";
export const deploy = async (job: DeployJob) => { export const deploy = async (job: DeployJob) => {
try { try {

View File

@@ -32,6 +32,8 @@ describe("Host rule format regression tests", () => {
previewDeploymentId: "", previewDeploymentId: "",
internalPath: "/", internalPath: "/",
stripPath: false, stripPath: false,
customEntrypoint: null,
middlewares: null,
}; };
describe("Host rule format validation", () => { describe("Host rule format validation", () => {

View File

@@ -7,6 +7,7 @@ describe("createDomainLabels", () => {
const baseDomain: Domain = { const baseDomain: Domain = {
host: "example.com", host: "example.com",
port: 8080, port: 8080,
customEntrypoint: null,
https: false, https: false,
uniqueConfigKey: 1, uniqueConfigKey: 1,
customCertResolver: null, customCertResolver: null,
@@ -21,6 +22,7 @@ describe("createDomainLabels", () => {
previewDeploymentId: "", previewDeploymentId: "",
internalPath: "/", internalPath: "/",
stripPath: false, stripPath: false,
middlewares: null,
}; };
it("should create basic labels for web entrypoint", async () => { it("should create basic labels for web entrypoint", async () => {
@@ -171,12 +173,12 @@ describe("createDomainLabels", () => {
"websecure", "websecure",
); );
// Web entrypoint should have both middlewares with redirect first // Web entrypoint with HTTPS should only have redirect
expect(webLabels).toContain( 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( expect(websecureLabels).toContain(
"traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1", "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", "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( 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", "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();
});
}); });

View File

@@ -292,7 +292,7 @@ networks:
dokploy-network: 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 composeData = parse(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash(); const suffix = generateRandomHash();

View File

@@ -195,7 +195,7 @@ services:
- dokploy-network - 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 composeData = parse(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash(); const suffix = generateRandomHash();
@@ -241,10 +241,10 @@ services:
dokploy-network: dokploy-network:
aliases: aliases:
- apid - 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 composeData = parse(composeFile8) as ComposeSpecification;
const suffix = generateRandomHash(); const suffix = generateRandomHash();

View File

@@ -14,13 +14,18 @@ vi.mock("@dokploy/server/db", () => {
set: vi.fn(() => chain), set: vi.fn(() => chain),
where: vi.fn(() => chain), where: vi.fn(() => chain),
returning: vi.fn().mockResolvedValue([{}] as any), returning: vi.fn().mockResolvedValue([{}] as any),
from: vi.fn(() => chain),
innerJoin: vi.fn(() => chain),
then: (resolve: (v: any) => void) => {
resolve([]);
},
} as any; } as any;
return chain; return chain;
}; };
return { return {
db: { db: {
select: vi.fn(), select: vi.fn(() => createChainableMock()),
insert: vi.fn(), insert: vi.fn(),
update: vi.fn(() => createChainableMock()), update: vi.fn(() => createChainableMock()),
delete: vi.fn(), delete: vi.fn(),
@@ -31,6 +36,9 @@ vi.mock("@dokploy/server/db", () => {
patch: { patch: {
findMany: vi.fn().mockResolvedValue([]), findMany: vi.fn().mockResolvedValue([]),
}, },
member: {
findMany: vi.fn().mockResolvedValue([]),
},
}, },
}, },
}; };

View File

@@ -15,13 +15,18 @@ vi.mock("@dokploy/server/db", () => {
set: vi.fn(() => chain), set: vi.fn(() => chain),
where: vi.fn(() => chain), where: vi.fn(() => chain),
returning: vi.fn().mockResolvedValue([{}]), returning: vi.fn().mockResolvedValue([{}]),
from: vi.fn(() => chain),
innerJoin: vi.fn(() => chain),
then: (resolve: (v: any) => void) => {
resolve([]);
},
}; };
return chain; return chain;
}; };
return { return {
db: { db: {
select: vi.fn(), select: vi.fn(() => createChainableMock()),
insert: vi.fn(), insert: vi.fn(),
update: vi.fn(() => createChainableMock()), update: vi.fn(() => createChainableMock()),
delete: vi.fn(), delete: vi.fn(),
@@ -32,6 +37,9 @@ vi.mock("@dokploy/server/db", () => {
patch: { patch: {
findMany: vi.fn().mockResolvedValue([]), findMany: vi.fn().mockResolvedValue([]),
}, },
member: {
findMany: vi.fn().mockResolvedValue([]),
},
}, },
}, },
}; };

View File

@@ -415,5 +415,24 @@ describe("Docker Image Name and Tag Extraction", () => {
expect(extractImageTag("my-image:123")).toBe("123"); expect(extractImageTag("my-image:123")).toBe("123");
expect(extractImageTag("my-image:1")).toBe("1"); 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",
);
});
}); });
}); });

View File

@@ -120,6 +120,7 @@ const baseApp: ApplicationNested = {
environmentId: "", environmentId: "",
enabled: null, enabled: null,
env: null, env: null,
icon: null,
healthCheckSwarm: null, healthCheckSwarm: null,
labelsSwarm: null, labelsSwarm: null,
memoryLimit: null, memoryLimit: null,

View File

@@ -1,4 +1,4 @@
import { getEnviromentVariablesObject } from "@dokploy/server/index"; import { getEnvironmentVariablesObject } from "@dokploy/server/index";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
const projectEnv = ` const projectEnv = `
@@ -15,7 +15,7 @@ DATABASE_NAME=dev_database
SECRET_KEY=env-secret-123 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", () => { it("resolves environment variables correctly for Stack compose", () => {
const serviceEnv = ` const serviceEnv = `
FOO=\${{environment.NODE_ENV}} FOO=\${{environment.NODE_ENV}}
@@ -23,7 +23,7 @@ BAR=\${{environment.API_URL}}
BAZ=test BAZ=test
`; `;
const result = getEnviromentVariablesObject( const result = getEnvironmentVariablesObject(
serviceEnv, serviceEnv,
projectEnv, projectEnv,
environmentEnv, environmentEnv,
@@ -45,7 +45,7 @@ DATABASE_URL=\${{project.DATABASE_URL}}
SERVICE_PORT=4000 SERVICE_PORT=4000
`; `;
const result = getEnviromentVariablesObject( const result = getEnvironmentVariablesObject(
serviceEnv, serviceEnv,
projectEnv, projectEnv,
environmentEnv, environmentEnv,
@@ -72,7 +72,7 @@ PASSWORD=secret123
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb 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({ expect(result).toEqual({
DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb", DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
@@ -85,7 +85,7 @@ UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}}
`; `;
expect(() => expect(() =>
getEnviromentVariablesObject(serviceWithUndefined, "", environmentEnv), getEnvironmentVariablesObject(serviceWithUndefined, "", environmentEnv),
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR"); ).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
}); });
@@ -95,7 +95,7 @@ NODE_ENV=production
API_URL=\${{environment.API_URL}} API_URL=\${{environment.API_URL}}
`; `;
const result = getEnviromentVariablesObject( const result = getEnvironmentVariablesObject(
serviceOverrideEnv, serviceOverrideEnv,
"", "",
environmentEnv, environmentEnv,
@@ -115,7 +115,7 @@ SERVICE_NAME=my-service
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}} COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
`; `;
const result = getEnviromentVariablesObject( const result = getEnvironmentVariablesObject(
complexServiceEnv, complexServiceEnv,
projectEnv, projectEnv,
environmentEnv, environmentEnv,
@@ -150,7 +150,7 @@ ENV_VAR=\${{environment.API_URL}}
DB_NAME=\${{environment.DATABASE_NAME}} DB_NAME=\${{environment.DATABASE_NAME}}
`; `;
const result = getEnviromentVariablesObject( const result = getEnvironmentVariablesObject(
serviceWithConflicts, serviceWithConflicts,
conflictingProjectEnv, conflictingProjectEnv,
conflictingEnvironmentEnv, conflictingEnvironmentEnv,
@@ -170,7 +170,7 @@ SERVICE_VAR=test
PROJECT_VAR=\${{project.ENVIRONMENT}} PROJECT_VAR=\${{project.ENVIRONMENT}}
`; `;
const result = getEnviromentVariablesObject( const result = getEnvironmentVariablesObject(
serviceWithEmpty, serviceWithEmpty,
projectEnv, projectEnv,
"", "",

View File

@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockMemberData = (
role: string,
overrides: Record<string, boolean> = {},
) => ({
id: "member-1",
role,
userId: "user-1",
organizationId: "org-1",
accessedProjects: [] as string[],
accessedServices: [] as string[],
accessedEnvironments: [] as string[],
canCreateProjects: overrides.canCreateProjects ?? false,
canDeleteProjects: overrides.canDeleteProjects ?? false,
canCreateServices: overrides.canCreateServices ?? false,
canDeleteServices: overrides.canDeleteServices ?? false,
canCreateEnvironments: overrides.canCreateEnvironments ?? false,
canDeleteEnvironments: overrides.canDeleteEnvironments ?? false,
canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false,
canAccessToDocker: overrides.canAccessToDocker ?? false,
canAccessToAPI: overrides.canAccessToAPI ?? false,
canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false,
canAccessToGitProviders: overrides.canAccessToGitProviders ?? false,
user: { id: "user-1", email: "test@test.com" },
});
let memberToReturn: ReturnType<typeof mockMemberData> =
mockMemberData("member");
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
findMany: vi.fn(() => Promise.resolve([])),
},
organizationRole: {
findFirst: vi.fn(),
findMany: vi.fn(() => Promise.resolve([])),
},
},
},
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: vi.fn(() => Promise.resolve(false)),
}));
const { checkPermission } = await import("@dokploy/server/services/permission");
const ctx = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("static roles bypass enterprise resources", () => {
it("owner bypasses deployment.read", async () => {
memberToReturn = mockMemberData("owner");
await expect(
checkPermission(ctx, { deployment: ["read"] }),
).resolves.toBeUndefined();
});
it("admin bypasses backup.create", async () => {
memberToReturn = mockMemberData("admin");
await expect(
checkPermission(ctx, { backup: ["create"] }),
).resolves.toBeUndefined();
});
it("member bypasses schedule.delete", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { schedule: ["delete"] }),
).resolves.toBeUndefined();
});
it("member bypasses multiple enterprise permissions at once", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, {
deployment: ["read"],
backup: ["create"],
domain: ["delete"],
}),
).resolves.toBeUndefined();
});
});
describe("static roles validate free-tier resources", () => {
it("owner passes project.create", async () => {
memberToReturn = mockMemberData("owner");
await expect(
checkPermission(ctx, { project: ["create"] }),
).resolves.toBeUndefined();
});
it("member fails project.create (no legacy override)", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { project: ["create"] }),
).rejects.toThrow();
});
it("member passes service.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { service: ["read"] }),
).resolves.toBeUndefined();
});
it("member fails service.create", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { service: ["create"] }),
).rejects.toThrow();
});
});
describe("legacy boolean overrides for member", () => {
it("member passes project.create with canCreateProjects=true", async () => {
memberToReturn = mockMemberData("member", { canCreateProjects: true });
await expect(
checkPermission(ctx, { project: ["create"] }),
).resolves.toBeUndefined();
});
it("member passes docker.read with canAccessToDocker=true", async () => {
memberToReturn = mockMemberData("member", { canAccessToDocker: true });
await expect(
checkPermission(ctx, { docker: ["read"] }),
).resolves.toBeUndefined();
});
it("member fails docker.read with canAccessToDocker=false", async () => {
memberToReturn = mockMemberData("member");
await expect(checkPermission(ctx, { docker: ["read"] })).rejects.toThrow();
});
});

View File

@@ -0,0 +1,79 @@
import {
enterpriseOnlyResources,
statements,
} from "@dokploy/server/lib/access-control";
import { describe, expect, it } from "vitest";
const FREE_TIER_RESOURCES = [
"organization",
"member",
"invitation",
"team",
"ac",
"project",
"service",
"environment",
"docker",
"sshKeys",
"gitProviders",
"traefikFiles",
"api",
];
const ENTERPRISE_RESOURCES = [
"volume",
"deployment",
"envVars",
"projectEnvVars",
"environmentEnvVars",
"server",
"registry",
"certificate",
"backup",
"volumeBackup",
"schedule",
"domain",
"destination",
"notification",
"tag",
"logs",
"monitoring",
"auditLog",
];
describe("enterpriseOnlyResources set", () => {
it("contains all enterprise resources", () => {
for (const resource of ENTERPRISE_RESOURCES) {
expect(enterpriseOnlyResources.has(resource)).toBe(true);
}
});
it("does NOT contain free-tier resources", () => {
for (const resource of FREE_TIER_RESOURCES) {
expect(enterpriseOnlyResources.has(resource)).toBe(false);
}
});
it("every resource in statements is either free or enterprise", () => {
const allResources = Object.keys(statements);
for (const resource of allResources) {
const isFree = FREE_TIER_RESOURCES.includes(resource);
const isEnterprise = enterpriseOnlyResources.has(resource);
expect(isFree || isEnterprise).toBe(true);
}
});
it("free and enterprise sets don't overlap", () => {
for (const resource of FREE_TIER_RESOURCES) {
expect(enterpriseOnlyResources.has(resource)).toBe(false);
}
});
it("all statement resources are accounted for", () => {
const allResources = Object.keys(statements);
const categorized = [...FREE_TIER_RESOURCES, ...ENTERPRISE_RESOURCES];
for (const resource of allResources) {
expect(categorized).toContain(resource);
}
});
});

View File

@@ -0,0 +1,161 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockMemberData = (
role: string,
overrides: Record<string, boolean> = {},
) => ({
id: "member-1",
role,
userId: "user-1",
organizationId: "org-1",
accessedProjects: [] as string[],
accessedServices: [] as string[],
accessedEnvironments: [] as string[],
canCreateProjects: overrides.canCreateProjects ?? false,
canDeleteProjects: overrides.canDeleteProjects ?? false,
canCreateServices: overrides.canCreateServices ?? false,
canDeleteServices: overrides.canDeleteServices ?? false,
canCreateEnvironments: overrides.canCreateEnvironments ?? false,
canDeleteEnvironments: overrides.canDeleteEnvironments ?? false,
canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false,
canAccessToDocker: overrides.canAccessToDocker ?? false,
canAccessToAPI: overrides.canAccessToAPI ?? false,
canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false,
canAccessToGitProviders: overrides.canAccessToGitProviders ?? false,
user: { id: "user-1", email: "test@test.com" },
});
let memberToReturn: ReturnType<typeof mockMemberData> =
mockMemberData("member");
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
findMany: vi.fn(() => Promise.resolve([])),
},
organizationRole: {
findFirst: vi.fn(),
findMany: vi.fn(() => Promise.resolve([])),
},
},
},
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: vi.fn(() => Promise.resolve(false)),
}));
const { resolvePermissions } = await import(
"@dokploy/server/services/permission"
);
const { enterpriseOnlyResources, statements } = await import(
"@dokploy/server/lib/access-control"
);
const ctx = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("enterprise resources for static roles", () => {
it("owner gets true for all enterprise resources", async () => {
memberToReturn = mockMemberData("owner");
const perms = await resolvePermissions(ctx);
for (const resource of enterpriseOnlyResources) {
const actions = statements[resource as keyof typeof statements];
for (const action of actions) {
expect((perms as any)[resource][action]).toBe(true);
}
}
});
it("admin gets true for all enterprise resources", async () => {
memberToReturn = mockMemberData("admin");
const perms = await resolvePermissions(ctx);
for (const resource of enterpriseOnlyResources) {
const actions = statements[resource as keyof typeof statements];
for (const action of actions) {
expect((perms as any)[resource][action]).toBe(true);
}
}
});
it("member gets true for service-level enterprise resources", async () => {
memberToReturn = mockMemberData("member");
const perms = await resolvePermissions(ctx);
expect(perms.deployment.read).toBe(true);
expect(perms.deployment.create).toBe(true);
expect(perms.domain.read).toBe(true);
expect(perms.backup.read).toBe(true);
expect(perms.logs.read).toBe(true);
expect(perms.monitoring.read).toBe(true);
});
it("member gets false for org-level enterprise resources", async () => {
memberToReturn = mockMemberData("member");
const perms = await resolvePermissions(ctx);
expect(perms.server.read).toBe(false);
expect(perms.registry.read).toBe(false);
expect(perms.certificate.read).toBe(false);
expect(perms.destination.read).toBe(false);
expect(perms.notification.read).toBe(false);
expect(perms.auditLog.read).toBe(false);
});
});
describe("free-tier resources for member", () => {
it("member gets service.read=true", async () => {
memberToReturn = mockMemberData("member");
const perms = await resolvePermissions(ctx);
expect(perms.service.read).toBe(true);
});
it("member gets project.create=false without legacy override", async () => {
memberToReturn = mockMemberData("member");
const perms = await resolvePermissions(ctx);
expect(perms.project.create).toBe(false);
});
it("member gets project.create=true with canCreateProjects", async () => {
memberToReturn = mockMemberData("member", { canCreateProjects: true });
const perms = await resolvePermissions(ctx);
expect(perms.project.create).toBe(true);
});
it("member gets docker.read=false without legacy override", async () => {
memberToReturn = mockMemberData("member");
const perms = await resolvePermissions(ctx);
expect(perms.docker.read).toBe(false);
});
it("member gets docker.read=true with canAccessToDocker", async () => {
memberToReturn = mockMemberData("member", { canAccessToDocker: true });
const perms = await resolvePermissions(ctx);
expect(perms.docker.read).toBe(true);
});
});
describe("free-tier resources for owner", () => {
it("owner gets all free-tier permissions as true", async () => {
memberToReturn = mockMemberData("owner");
const perms = await resolvePermissions(ctx);
expect(perms.project.create).toBe(true);
expect(perms.project.delete).toBe(true);
expect(perms.service.create).toBe(true);
expect(perms.service.read).toBe(true);
expect(perms.service.delete).toBe(true);
expect(perms.docker.read).toBe(true);
expect(perms.traefikFiles.read).toBe(true);
expect(perms.traefikFiles.write).toBe(true);
});
});

View File

@@ -0,0 +1,132 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockMemberData = (
role: string,
accessedServices: string[] = [],
accessedProjects: string[] = [],
) => ({
id: "member-1",
role,
userId: "user-1",
organizationId: "org-1",
accessedProjects,
accessedServices,
accessedEnvironments: [] as string[],
canCreateProjects: false,
canDeleteProjects: false,
canCreateServices: false,
canDeleteServices: false,
canCreateEnvironments: false,
canDeleteEnvironments: false,
canAccessToTraefikFiles: false,
canAccessToDocker: false,
canAccessToAPI: false,
canAccessToSSHKeys: false,
canAccessToGitProviders: false,
user: { id: "user-1", email: "test@test.com" },
});
let memberToReturn: ReturnType<typeof mockMemberData> =
mockMemberData("member");
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
findMany: vi.fn(() => Promise.resolve([])),
},
organizationRole: {
findFirst: vi.fn(),
findMany: vi.fn(() => Promise.resolve([])),
},
},
},
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: vi.fn(() => Promise.resolve(false)),
}));
const { checkServicePermissionAndAccess, checkServiceAccess } = await import(
"@dokploy/server/services/permission"
);
const ctx = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("checkServicePermissionAndAccess", () => {
it("owner bypasses accessedServices check", async () => {
memberToReturn = mockMemberData("owner", []);
await expect(
checkServicePermissionAndAccess(ctx, "service-123", {
deployment: ["read"],
}),
).resolves.toBeUndefined();
});
it("admin bypasses accessedServices check", async () => {
memberToReturn = mockMemberData("admin", []);
await expect(
checkServicePermissionAndAccess(ctx, "service-123", {
backup: ["create"],
}),
).resolves.toBeUndefined();
});
it("member with access to service passes", async () => {
memberToReturn = mockMemberData("member", ["service-123"]);
await expect(
checkServicePermissionAndAccess(ctx, "service-123", {
deployment: ["read"],
}),
).resolves.toBeUndefined();
});
it("member WITHOUT access to service fails", async () => {
memberToReturn = mockMemberData("member", ["other-service"]);
await expect(
checkServicePermissionAndAccess(ctx, "service-123", {
deployment: ["read"],
}),
).rejects.toThrow("You don't have access to this service");
});
it("member with empty accessedServices fails", async () => {
memberToReturn = mockMemberData("member", []);
await expect(
checkServicePermissionAndAccess(ctx, "service-123", {
domain: ["delete"],
}),
).rejects.toThrow("You don't have access to this service");
});
});
describe("checkServiceAccess", () => {
it("member with service access passes read check", async () => {
memberToReturn = mockMemberData("member", ["app-1"]);
await expect(
checkServiceAccess(ctx, "app-1", "read"),
).resolves.toBeUndefined();
});
it("member without service access fails read check", async () => {
memberToReturn = mockMemberData("member", []);
await expect(checkServiceAccess(ctx, "app-1", "read")).rejects.toThrow(
"You don't have access to this service",
);
});
it("owner bypasses all access checks", async () => {
memberToReturn = mockMemberData("owner", [], []);
await expect(
checkServiceAccess(ctx, "project-1", "create"),
).resolves.toBeUndefined();
});
});

View File

@@ -57,7 +57,7 @@ const createApplication = (
env: null, env: null,
}, },
replicas: 1, replicas: 1,
stopGracePeriodSwarm: 0n, stopGracePeriodSwarm: 0,
ulimitsSwarm: null, ulimitsSwarm: null,
serverId: "server-id", serverId: "server-id",
...overrides, ...overrides,
@@ -76,8 +76,8 @@ describe("mechanizeDockerContainer", () => {
}); });
}); });
it("converts bigint stopGracePeriodSwarm to a number and keeps zero values", async () => { it("passes stopGracePeriodSwarm as a number and keeps zero values", async () => {
const application = createApplication({ stopGracePeriodSwarm: 0n }); const application = createApplication({ stopGracePeriodSwarm: 0 });
await mechanizeDockerContainer(application); await mechanizeDockerContainer(application);

View File

@@ -12,7 +12,11 @@ vi.mock("@dokploy/server/db", () => {
chain.where = () => chain; chain.where = () => chain;
chain.values = () => chain; chain.values = () => chain;
chain.returning = () => Promise.resolve([{}]); chain.returning = () => Promise.resolve([{}]);
chain.then = undefined; chain.from = () => chain;
chain.innerJoin = () => chain;
chain.then = (resolve: (value: unknown) => void) => {
resolve([]);
};
const tableMock = { const tableMock = {
findFirst: vi.fn(() => Promise.resolve(undefined)), findFirst: vi.fn(() => Promise.resolve(undefined)),
@@ -21,7 +25,6 @@ vi.mock("@dokploy/server/db", () => {
update: vi.fn(() => chain), update: vi.fn(() => chain),
delete: vi.fn(() => chain), delete: vi.fn(() => chain),
}; };
const createQueryMock = () => tableMock;
return { return {
db: { db: {

View File

@@ -48,6 +48,20 @@ const baseSettings: WebServerSettings = {
urlCallback: "", urlCallback: "",
}, },
}, },
whitelabelingConfig: {
appName: null,
appDescription: null,
logoUrl: null,
faviconUrl: null,
customCss: null,
loginLogoUrl: null,
supportUrl: null,
docsUrl: null,
errorPageTitle: null,
errorPageDescription: null,
metaTitle: null,
footerText: null,
},
cleanupCacheApplications: false, cleanupCacheApplications: false,
cleanupCacheOnCompose: false, cleanupCacheOnCompose: false,
cleanupCacheOnPreviews: false, cleanupCacheOnPreviews: false,

View File

@@ -95,6 +95,7 @@ const baseApp: ApplicationNested = {
dropBuildPath: null, dropBuildPath: null,
enabled: null, enabled: null,
env: null, env: null,
icon: null,
healthCheckSwarm: null, healthCheckSwarm: null,
labelsSwarm: null, labelsSwarm: null,
memoryLimit: null, memoryLimit: null,
@@ -137,6 +138,7 @@ const baseDomain: Domain = {
https: false, https: false,
path: null, path: null,
port: null, port: null,
customEntrypoint: null,
serviceName: "", serviceName: "",
composeId: "", composeId: "",
customCertResolver: null, customCertResolver: null,
@@ -145,6 +147,7 @@ const baseDomain: Domain = {
previewDeploymentId: "", previewDeploymentId: "",
internalPath: "/", internalPath: "/",
stripPath: false, stripPath: false,
middlewares: null,
}; };
const baseRedirect: Redirect = { const baseRedirect: Redirect = {
@@ -264,6 +267,80 @@ test("Websecure entrypoint on https domain with redirect", async () => {
expect(router.middlewares).toContain("redirect-test-1"); 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 */ /** Certificates */
test("CertificateType on websecure entrypoint", async () => { test("CertificateType on websecure entrypoint", async () => {
@@ -276,6 +353,130 @@ test("CertificateType on websecure entrypoint", async () => {
expect(router.tls?.certResolver).toBe("letsencrypt"); 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 */ /** IDN/Punycode */
test("Internationalized domain name is converted to punycode", async () => { test("Internationalized domain name is converted to punycode", async () => {

View File

@@ -112,14 +112,21 @@ const menuItems: MenuItem[] = [
const hasStopGracePeriodSwarm = ( const hasStopGracePeriodSwarm = (
value: unknown, value: unknown,
): value is { stopGracePeriodSwarm: bigint | number | string | null } => ): value is { stopGracePeriodSwarm: number | string | null } =>
typeof value === "object" && typeof value === "object" &&
value !== null && value !== null &&
"stopGracePeriodSwarm" in value; "stopGracePeriodSwarm" in value;
interface Props { interface Props {
id: string; id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; type:
| "application"
| "libsql"
| "mariadb"
| "mongo"
| "mysql"
| "postgres"
| "redis";
} }
export const AddSwarmSettings = ({ id, type }: Props) => { export const AddSwarmSettings = ({ id, type }: Props) => {

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Server } from "lucide-react"; import { Server } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -37,27 +37,27 @@ import { AddSwarmSettings } from "./modify-swarm-settings";
interface Props { interface Props {
id: string; 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"), replicas: z.number().min(1, "Replicas must be at least 1"),
registryId: z.string().optional(), registryId: z.string().optional(),
}); });
type AddCommand = z.infer<typeof AddRedirectchema>; type AddCommand = z.infer<typeof AddRedirectSchema>;
export const ShowClusterSettings = ({ id, type }: Props) => { export const ShowClusterSettings = ({ id, type }: Props) => {
const queryMap = { 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: () => postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: 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] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
@@ -65,15 +65,16 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
const { data: registries } = api.registry.all.useQuery(); const { data: registries } = api.registry.all.useQuery();
const mutationMap = { 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(), postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(), redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
}; };
const { mutateAsync, isLoading } = mutationMap[type] const { mutateAsync, isPending } = mutationMap[type]
? mutationMap[type]() ? mutationMap[type]()
: api.mongo.update.useMutation(); : api.mongo.update.useMutation();
@@ -86,7 +87,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
: {}), : {}),
replicas: data?.replicas || 1, replicas: data?.replicas || 1,
}, },
resolver: zodResolver(AddRedirectchema), resolver: zodResolver(AddRedirectSchema),
}); });
useEffect(() => { useEffect(() => {
@@ -105,11 +106,11 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
const onSubmit = async (data: AddCommand) => { const onSubmit = async (data: AddCommand) => {
await mutateAsync({ await mutateAsync({
applicationId: id || "", applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "", mariadbId: id || "",
mongoId: id || "", mongoId: id || "",
mysqlId: id || "",
postgresId: id || "",
redisId: id || "",
...(type === "application" ...(type === "application"
? { ? {
registryId: registryId:
@@ -236,7 +237,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
)} )}
<div className="flex justify-end"> <div className="flex justify-end">
<Button isLoading={isLoading} type="submit" className="w-fit"> <Button isLoading={isPending} type="submit" className="w-fit">
Save Save
</Button> </Button>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -28,7 +28,14 @@ export const endpointSpecFormSchema = z.object({
interface EndpointSpecFormProps { interface EndpointSpecFormProps {
id: string; id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
} }
export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => { export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
@@ -44,6 +51,7 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
application: () => application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: 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] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
@@ -56,6 +64,7 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
mariadb: () => api.mariadb.update.useMutation(), mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(), application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(), mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
}; };
const { mutateAsync } = mutationMap[type] const { mutateAsync } = mutationMap[type]
@@ -94,6 +103,7 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
mysqlId: id || "", mysqlId: id || "",
mariadbId: id || "", mariadbId: id || "",
mongoId: id || "", mongoId: id || "",
libsqlId: id || "",
endpointSpecSwarm: hasAnyValue ? formData : null, endpointSpecSwarm: hasAnyValue ? formData : null,
}); });

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -26,7 +26,14 @@ export const healthCheckFormSchema = z.object({
interface HealthCheckFormProps { interface HealthCheckFormProps {
id: string; id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
} }
export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => { export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
@@ -42,6 +49,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
application: () => application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: 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] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
@@ -54,6 +62,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
mariadb: () => api.mariadb.update.useMutation(), mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(), application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(), mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
}; };
const { mutateAsync } = mutationMap[type] const { mutateAsync } = mutationMap[type]
@@ -104,6 +113,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
mysqlId: id || "", mysqlId: id || "",
mariadbId: id || "", mariadbId: id || "",
mongoId: id || "", mongoId: id || "",
libsqlId: id || "",
healthCheckSwarm: hasAnyValue ? formData : null, healthCheckSwarm: hasAnyValue ? formData : null,
}); });

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form"; import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -29,7 +29,14 @@ export const labelsFormSchema = z.object({
interface LabelsFormProps { interface LabelsFormProps {
id: string; id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
} }
export const LabelsForm = ({ id, type }: LabelsFormProps) => { export const LabelsForm = ({ id, type }: LabelsFormProps) => {
@@ -45,6 +52,7 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
application: () => application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: 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] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
@@ -57,6 +65,7 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
mariadb: () => api.mariadb.update.useMutation(), mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(), application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(), mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
}; };
const { mutateAsync } = mutationMap[type] const { mutateAsync } = mutationMap[type]
@@ -112,6 +121,7 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
mysqlId: id || "", mysqlId: id || "",
mariadbId: id || "", mariadbId: id || "",
mongoId: id || "", mongoId: id || "",
libsqlId: id || "",
labelsSwarm: labelsToSend, labelsSwarm: labelsToSend,
}); });

View File

@@ -23,7 +23,14 @@ import { api } from "@/utils/api";
interface ModeFormProps { interface ModeFormProps {
id: string; id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
} }
export const ModeForm = ({ id, type }: ModeFormProps) => { export const ModeForm = ({ id, type }: ModeFormProps) => {
@@ -39,6 +46,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
application: () => application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: 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] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
@@ -51,6 +59,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
mariadb: () => api.mariadb.update.useMutation(), mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(), application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(), mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
}; };
const { mutateAsync } = mutationMap[type] const { mutateAsync } = mutationMap[type]
@@ -95,6 +104,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
mysqlId: id || "", mysqlId: id || "",
mariadbId: id || "", mariadbId: id || "",
mongoId: id || "", mongoId: id || "",
libsqlId: id || "",
modeSwarm: null, modeSwarm: null,
}); });
toast.success("Mode updated successfully"); toast.success("Mode updated successfully");
@@ -122,6 +132,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
mysqlId: id || "", mysqlId: id || "",
mariadbId: id || "", mariadbId: id || "",
mongoId: id || "", mongoId: id || "",
libsqlId: id || "",
modeSwarm: modeData, modeSwarm: modeData,
}); });

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form"; import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -35,7 +35,14 @@ export const networkFormSchema = z.object({
interface NetworkFormProps { interface NetworkFormProps {
id: string; id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
} }
export const NetworkForm = ({ id, type }: NetworkFormProps) => { export const NetworkForm = ({ id, type }: NetworkFormProps) => {
@@ -51,6 +58,7 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
application: () => application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: 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] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
@@ -63,6 +71,7 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
mariadb: () => api.mariadb.update.useMutation(), mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(), application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(), mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
}; };
const { mutateAsync } = mutationMap[type] const { mutateAsync } = mutationMap[type]
@@ -132,6 +141,7 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
mysqlId: id || "", mysqlId: id || "",
mariadbId: id || "", mariadbId: id || "",
mongoId: id || "", mongoId: id || "",
libsqlId: id || "",
networkSwarm: networksToSend, networkSwarm: networksToSend,
}); });

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -34,7 +34,14 @@ export const placementFormSchema = z.object({
interface PlacementFormProps { interface PlacementFormProps {
id: string; id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
} }
export const PlacementForm = ({ id, type }: PlacementFormProps) => { export const PlacementForm = ({ id, type }: PlacementFormProps) => {
@@ -50,6 +57,7 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
application: () => application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: 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] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
@@ -62,6 +70,7 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
mariadb: () => api.mariadb.update.useMutation(), mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(), application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(), mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
}; };
const { mutateAsync } = mutationMap[type] const { mutateAsync } = mutationMap[type]
@@ -114,6 +123,7 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
mysqlId: id || "", mysqlId: id || "",
mariadbId: id || "", mariadbId: id || "",
mongoId: id || "", mongoId: id || "",
libsqlId: id || "",
placementSwarm: hasAnyValue placementSwarm: hasAnyValue
? { ? {
...formData, ...formData,

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -32,7 +32,14 @@ export const restartPolicyFormSchema = z.object({
interface RestartPolicyFormProps { interface RestartPolicyFormProps {
id: string; id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
} }
export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => { export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
@@ -48,6 +55,7 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
application: () => application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: 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] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
@@ -60,6 +68,7 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
mariadb: () => api.mariadb.update.useMutation(), mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(), application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(), mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
}; };
const { mutateAsync } = mutationMap[type] const { mutateAsync } = mutationMap[type]
@@ -104,6 +113,7 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
mysqlId: id || "", mysqlId: id || "",
mariadbId: id || "", mariadbId: id || "",
mongoId: id || "", mongoId: id || "",
libsqlId: id || "",
restartPolicySwarm: hasAnyValue ? formData : null, restartPolicySwarm: hasAnyValue ? formData : null,
}); });

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -34,7 +34,14 @@ export const rollbackConfigFormSchema = z.object({
interface RollbackConfigFormProps { interface RollbackConfigFormProps {
id: string; id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
} }
export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => { export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
@@ -50,6 +57,7 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
application: () => application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: 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] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
@@ -62,6 +70,7 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
mariadb: () => api.mariadb.update.useMutation(), mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(), application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(), mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
}; };
const { mutateAsync } = mutationMap[type] const { mutateAsync } = mutationMap[type]
@@ -103,6 +112,7 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
mysqlId: id || "", mysqlId: id || "",
mariadbId: id || "", mariadbId: id || "",
mongoId: id || "", mongoId: id || "",
libsqlId: id || "",
rollbackConfigSwarm: (hasAnyValue ? formData : null) as any, rollbackConfigSwarm: (hasAnyValue ? formData : null) as any,
}); });

View File

@@ -16,14 +16,21 @@ import { api } from "@/utils/api";
const hasStopGracePeriodSwarm = ( const hasStopGracePeriodSwarm = (
value: unknown, value: unknown,
): value is { stopGracePeriodSwarm: bigint | number | string | null } => ): value is { stopGracePeriodSwarm: number | string | null } =>
typeof value === "object" && typeof value === "object" &&
value !== null && value !== null &&
"stopGracePeriodSwarm" in value; "stopGracePeriodSwarm" in value;
interface StopGracePeriodFormProps { interface StopGracePeriodFormProps {
id: string; id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
} }
export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => { export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
@@ -39,6 +46,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
application: () => application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: 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] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
@@ -51,6 +59,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
mariadb: () => api.mariadb.update.useMutation(), mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(), application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(), mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
}; };
const { mutateAsync } = mutationMap[type] const { mutateAsync } = mutationMap[type]
@@ -59,7 +68,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
const form = useForm<any>({ const form = useForm<any>({
defaultValues: { defaultValues: {
value: null as bigint | null, value: null as number | null,
}, },
}); });
@@ -67,11 +76,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
if (hasStopGracePeriodSwarm(data)) { if (hasStopGracePeriodSwarm(data)) {
const value = data.stopGracePeriodSwarm; const value = data.stopGracePeriodSwarm;
const normalizedValue = const normalizedValue =
value === null || value === undefined value === null || value === undefined ? null : Number(value);
? null
: typeof value === "bigint"
? value
: BigInt(value);
form.reset({ form.reset({
value: normalizedValue, value: normalizedValue,
}); });
@@ -88,6 +93,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
mysqlId: id || "", mysqlId: id || "",
mariadbId: id || "", mariadbId: id || "",
mongoId: id || "", mongoId: id || "",
libsqlId: id || "",
stopGracePeriodSwarm: formData.value, stopGracePeriodSwarm: formData.value,
}); });
@@ -126,7 +132,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
} }
onChange={(e) => onChange={(e) =>
field.onChange( field.onChange(
e.target.value ? BigInt(e.target.value) : null, e.target.value ? Number(e.target.value) : null,
) )
} }
/> />

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -34,7 +34,14 @@ export const updateConfigFormSchema = z.object({
interface UpdateConfigFormProps { interface UpdateConfigFormProps {
id: string; id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
} }
export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => { export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
@@ -50,6 +57,7 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
application: () => application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: 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] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
@@ -62,6 +70,7 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
mariadb: () => api.mariadb.update.useMutation(), mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(), application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(), mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
}; };
const { mutateAsync } = mutationMap[type] const { mutateAsync } = mutationMap[type]
@@ -109,6 +118,7 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
mysqlId: id || "", mysqlId: id || "",
mariadbId: id || "", mariadbId: id || "",
mongoId: id || "", mongoId: id || "",
libsqlId: id || "",
updateConfigSwarm: (hasAnyValue ? formData : null) as any, updateConfigSwarm: (hasAnyValue ? formData : null) as any,
}); });

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useFieldArray, useForm } from "react-hook-form"; import { useFieldArray, useForm } from "react-hook-form";
@@ -50,7 +50,7 @@ export const AddCommand = ({ applicationId }: Props) => {
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync, isLoading } = api.application.update.useMutation(); const { mutateAsync, isPending } = api.application.update.useMutation();
const form = useForm<AddCommand>({ const form = useForm<AddCommand>({
defaultValues: { defaultValues: {
@@ -177,7 +177,7 @@ export const AddCommand = ({ applicationId }: Props) => {
</div> </div>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button isLoading={isLoading} type="submit" className="w-fit"> <Button isLoading={isPending} type="submit" className="w-fit">
Save Save
</Button> </Button>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Code2, Globe2, HardDrive } from "lucide-react"; import { Code2, Globe2, HardDrive } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -69,11 +69,11 @@ export const ShowImport = ({ composeId }: Props) => {
} | null>(null); } | null>(null);
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync: processTemplate, isLoading: isLoadingTemplate } = const { mutateAsync: processTemplate, isPending: isLoadingTemplate } =
api.compose.processTemplate.useMutation(); api.compose.processTemplate.useMutation();
const { const {
mutateAsync: importTemplate, mutateAsync: importTemplate,
isLoading: isImporting, isPending: isImporting,
isSuccess: isImportSuccess, isSuccess: isImportSuccess,
} = api.compose.import.useMutation(); } = api.compose.import.useMutation();

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, PlusIcon } from "lucide-react"; import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm, useWatch } from "react-hook-form"; import { useForm, useWatch } from "react-hook-form";
@@ -35,13 +35,9 @@ import { api } from "@/utils/api";
const AddPortSchema = z.object({ const AddPortSchema = z.object({
publishedPort: z.number().int().min(1).max(65535), publishedPort: z.number().int().min(1).max(65535),
publishMode: z.enum(["ingress", "host"], { publishMode: z.enum(["ingress", "host"]),
required_error: "Publish mode is required",
}),
targetPort: z.number().int().min(1).max(65535), targetPort: z.number().int().min(1).max(65535),
protocol: z.enum(["tcp", "udp"], { protocol: z.enum(["tcp", "udp"]),
required_error: "Protocol is required",
}),
}); });
type AddPort = z.infer<typeof AddPortSchema>; type AddPort = z.infer<typeof AddPortSchema>;
@@ -68,7 +64,7 @@ export const HandlePorts = ({
enabled: !!portId, enabled: !!portId,
}, },
); );
const { mutateAsync, isLoading, error, isError } = portId const { mutateAsync, isPending, error, isError } = portId
? api.port.update.useMutation() ? api.port.update.useMutation()
: api.port.create.useMutation(); : api.port.create.useMutation();
@@ -270,7 +266,7 @@ export const HandlePorts = ({
<DialogFooter> <DialogFooter>
<Button <Button
isLoading={isLoading} isLoading={isPending}
form="hook-form-add-port" form="hook-form-add-port"
type="submit" type="submit"
> >

View File

@@ -25,7 +25,7 @@ export const ShowPorts = ({ applicationId }: Props) => {
{ enabled: !!applicationId }, { enabled: !!applicationId },
); );
const { mutateAsync: deletePort, isLoading: isRemoving } = const { mutateAsync: deletePort, isPending: isRemoving } =
api.port.delete.useMutation(); api.port.delete.useMutation();
return ( return (

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, PlusIcon } from "lucide-react"; import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -37,13 +37,13 @@ import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
const AddRedirectchema = z.object({ const AddRedirectSchema = z.object({
regex: z.string().min(1, "Regex required"), regex: z.string().min(1, "Regex required"),
permanent: z.boolean().default(false), permanent: z.boolean().default(false),
replacement: z.string().min(1, "Replacement required"), replacement: z.string().min(1, "Replacement required"),
}); });
type AddRedirect = z.infer<typeof AddRedirectchema>; type AddRedirect = z.infer<typeof AddRedirectSchema>;
// Default presets // Default presets
const redirectPresets = [ const redirectPresets = [
@@ -100,17 +100,17 @@ export const HandleRedirect = ({
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync, isLoading, error, isError } = redirectId const { mutateAsync, isPending, error, isError } = redirectId
? api.redirects.update.useMutation() ? api.redirects.update.useMutation()
: api.redirects.create.useMutation(); : api.redirects.create.useMutation();
const form = useForm<AddRedirect>({ const form = useForm({
defaultValues: { defaultValues: {
permanent: false, permanent: false,
regex: "", regex: "",
replacement: "", replacement: "",
}, },
resolver: zodResolver(AddRedirectchema), resolver: zodResolver(AddRedirectSchema),
}); });
useEffect(() => { useEffect(() => {
@@ -149,7 +149,7 @@ export const HandleRedirect = ({
const onDialogToggle = (open: boolean) => { const onDialogToggle = (open: boolean) => {
setIsOpen(open); 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(""); // setPresetSelected("");
// form.reset(); // form.reset();
}; };
@@ -268,7 +268,7 @@ export const HandleRedirect = ({
<DialogFooter> <DialogFooter>
<Button <Button
isLoading={isLoading} isLoading={isPending}
form="hook-form-add-redirect" form="hook-form-add-redirect"
type="submit" type="submit"
> >

View File

@@ -24,7 +24,7 @@ export const ShowRedirects = ({ applicationId }: Props) => {
{ enabled: !!applicationId }, { enabled: !!applicationId },
); );
const { mutateAsync: deleteRedirect, isLoading: isRemoving } = const { mutateAsync: deleteRedirect, isPending: isRemoving } =
api.redirects.delete.useMutation(); api.redirects.delete.useMutation();
const utils = api.useUtils(); const utils = api.useUtils();

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, PlusIcon } from "lucide-react"; import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -46,7 +46,7 @@ export const HandleSecurity = ({
}: Props) => { }: Props) => {
const utils = api.useUtils(); const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { data } = api.security.one.useQuery( const { data, refetch } = api.security.one.useQuery(
{ {
securityId: securityId ?? "", securityId: securityId ?? "",
}, },
@@ -55,7 +55,7 @@ export const HandleSecurity = ({
}, },
); );
const { mutateAsync, isLoading, error, isError } = securityId const { mutateAsync, isPending, error, isError } = securityId
? api.security.update.useMutation() ? api.security.update.useMutation()
: api.security.create.useMutation(); : api.security.create.useMutation();
@@ -88,6 +88,7 @@ export const HandleSecurity = ({
await utils.application.readTraefikConfig.invalidate({ await utils.application.readTraefikConfig.invalidate({
applicationId, applicationId,
}); });
await refetch();
setIsOpen(false); setIsOpen(false);
}) })
.catch(() => { .catch(() => {
@@ -163,7 +164,7 @@ export const HandleSecurity = ({
<DialogFooter> <DialogFooter>
<Button <Button
isLoading={isLoading} isLoading={isPending}
form="hook-form-add-security" form="hook-form-add-security"
type="submit" type="submit"
> >

View File

@@ -27,7 +27,7 @@ export const ShowSecurity = ({ applicationId }: Props) => {
{ enabled: !!applicationId }, { enabled: !!applicationId },
); );
const { mutateAsync: deleteSecurity, isLoading: isRemoving } = const { mutateAsync: deleteSecurity, isPending: isRemoving } =
api.security.delete.useMutation(); api.security.delete.useMutation();
const utils = api.useUtils(); const utils = api.useUtils();

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Server } from "lucide-react"; import { Server } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -74,7 +74,7 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
const { data: buildServers } = api.server.buildServers.useQuery(); const { data: buildServers } = api.server.buildServers.useQuery();
const { data: registries } = api.registry.all.useQuery(); const { data: registries } = api.registry.all.useQuery();
const { mutateAsync, isLoading } = api.application.update.useMutation(); const { mutateAsync, isPending } = api.application.update.useMutation();
const form = useForm<Schema>({ const form = useForm<Schema>({
defaultValues: { defaultValues: {
@@ -274,7 +274,7 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
/> />
<div className="flex w-full justify-end"> <div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit"> <Button isLoading={isPending} type="submit">
Save Save
</Button> </Button>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { InfoIcon, Plus, Trash2 } from "lucide-react"; import { InfoIcon, Plus, Trash2 } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useFieldArray, useForm } from "react-hook-form"; import { useFieldArray, useForm } from "react-hook-form";
@@ -89,12 +89,13 @@ const ULIMIT_PRESETS = [
]; ];
export type ServiceType = export type ServiceType =
| "postgres" | "application"
| "mongo" | "libsql"
| "redis"
| "mysql"
| "mariadb" | "mariadb"
| "application"; | "mongo"
| "mysql"
| "postgres"
| "redis";
interface Props { interface Props {
id: string; id: string;
@@ -105,34 +106,36 @@ type AddResources = z.infer<typeof addResourcesSchema>;
export const ShowResources = ({ id, type }: Props) => { export const ShowResources = ({ id, type }: Props) => {
const queryMap = { 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: () => postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: 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] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = { 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(), postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(), redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
}; };
const { mutateAsync, isLoading } = mutationMap[type] const { mutateAsync, isPending } = mutationMap[type]
? mutationMap[type]() ? mutationMap[type]()
: api.mongo.update.useMutation(); : api.mongo.update.useMutation();
const form = useForm<AddResources>({ const form = useForm({
defaultValues: { defaultValues: {
cpuLimit: "", cpuLimit: "",
cpuReservation: "", cpuReservation: "",
@@ -155,19 +158,20 @@ export const ShowResources = ({ id, type }: Props) => {
cpuReservation: data?.cpuReservation || undefined, cpuReservation: data?.cpuReservation || undefined,
memoryLimit: data?.memoryLimit || undefined, memoryLimit: data?.memoryLimit || undefined,
memoryReservation: data?.memoryReservation || undefined, memoryReservation: data?.memoryReservation || undefined,
ulimitsSwarm: data?.ulimitsSwarm || [], ulimitsSwarm: (data as any)?.ulimitsSwarm || [],
}); });
} }
}, [data, form, form.reset]); }, [data, form, form.reset]);
const onSubmit = async (formData: AddResources) => { const onSubmit = async (formData: AddResources) => {
await mutateAsync({ await mutateAsync({
applicationId: id || "",
libsqlId: id || "",
mariadbId: id || "",
mongoId: id || "", mongoId: id || "",
mysqlId: id || "",
postgresId: id || "", postgresId: id || "",
redisId: id || "", redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
applicationId: id || "",
cpuLimit: formData.cpuLimit || null, cpuLimit: formData.cpuLimit || null,
cpuReservation: formData.cpuReservation || null, cpuReservation: formData.cpuReservation || null,
memoryLimit: formData.memoryLimit || null, memoryLimit: formData.memoryLimit || null,
@@ -452,6 +456,11 @@ export const ShowResources = ({ id, type }: Props) => {
min={-1} min={-1}
placeholder="65535" placeholder="65535"
{...field} {...field}
value={
typeof field.value === "number"
? field.value
: ""
}
onChange={(e) => onChange={(e) =>
field.onChange(Number(e.target.value)) field.onChange(Number(e.target.value))
} }
@@ -475,6 +484,11 @@ export const ShowResources = ({ id, type }: Props) => {
min={-1} min={-1}
placeholder="65535" placeholder="65535"
{...field} {...field}
value={
typeof field.value === "number"
? field.value
: ""
}
onChange={(e) => onChange={(e) =>
field.onChange(Number(e.target.value)) field.onChange(Number(e.target.value))
} }
@@ -507,7 +521,7 @@ export const ShowResources = ({ id, type }: Props) => {
</div> </div>
<div className="flex w-full justify-end"> <div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit"> <Button isLoading={isPending} type="submit">
Save Save
</Button> </Button>
</div> </div>

View File

@@ -15,13 +15,17 @@ interface Props {
} }
export const ShowTraefikConfig = ({ applicationId }: Props) => { export const ShowTraefikConfig = ({ applicationId }: Props) => {
const { data, isLoading } = api.application.readTraefikConfig.useQuery( const { data: permissions } = api.user.getPermissions.useQuery();
const canRead = permissions?.traefikFiles.read ?? false;
const { data, isPending } = api.application.readTraefikConfig.useQuery(
{ {
applicationId, applicationId,
}, },
{ enabled: !!applicationId }, { enabled: !!applicationId && canRead },
); );
if (!canRead) return null;
return ( return (
<Card className="bg-background"> <Card className="bg-background">
<CardHeader className="flex flex-row justify-between"> <CardHeader className="flex flex-row justify-between">
@@ -35,7 +39,7 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-4">
{isLoading ? ( {isPending ? (
<span className="text-base text-muted-foreground flex flex-row gap-3 items-center justify-center min-h-[10vh]"> <span className="text-base text-muted-foreground flex flex-row gap-3 items-center justify-center min-h-[10vh]">
Loading... Loading...
<Loader2 className="animate-spin" /> <Loader2 className="animate-spin" />

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -7,6 +7,7 @@ import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor"; import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -24,7 +25,6 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
@@ -60,6 +60,8 @@ export const validateAndFormatYAML = (yamlText: string) => {
}; };
export const UpdateTraefikConfig = ({ applicationId }: Props) => { export const UpdateTraefikConfig = ({ applicationId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canWrite = permissions?.traefikFiles.write ?? false;
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [skipYamlValidation, setSkipYamlValidation] = useState(false); const [skipYamlValidation, setSkipYamlValidation] = useState(false);
const { data, refetch } = api.application.readTraefikConfig.useQuery( const { data, refetch } = api.application.readTraefikConfig.useQuery(
@@ -69,7 +71,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
{ enabled: !!applicationId }, { enabled: !!applicationId },
); );
const { mutateAsync, isLoading, error, isError } = const { mutateAsync, isPending, error, isError } =
api.application.updateTraefikConfig.useMutation(); api.application.updateTraefikConfig.useMutation();
const form = useForm<UpdateTraefikConfig>({ const form = useForm<UpdateTraefikConfig>({
@@ -125,9 +127,11 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
} }
}} }}
> >
<DialogTrigger asChild> {canWrite && (
<Button isLoading={isLoading}>Modify</Button> <DialogTrigger asChild>
</DialogTrigger> <Button isLoading={isPending}>Modify</Button>
</DialogTrigger>
)}
<DialogContent className="sm:max-w-4xl"> <DialogContent className="sm:max-w-4xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Update traefik config</DialogTitle> <DialogTitle>Update traefik config</DialogTitle>
@@ -198,7 +202,7 @@ routers:
</p> </p>
</div> </div>
<Button <Button
isLoading={isLoading} isLoading={isPending}
form="hook-form-update-traefik-config" form="hook-form-update-traefik-config"
type="submit" type="submit"
> >

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import type React from "react"; import type React from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -34,13 +34,13 @@ interface Props {
serviceId: string; serviceId: string;
serviceType: serviceType:
| "application" | "application"
| "postgres" | "compose"
| "redis" | "libsql"
| "mongo"
| "redis"
| "mysql"
| "mariadb" | "mariadb"
| "compose"; | "mongo"
| "mysql"
| "postgres"
| "redis";
refetch: () => void; refetch: () => void;
children?: React.ReactNode; children?: React.ReactNode;
} }

View File

@@ -21,24 +21,33 @@ interface Props {
} }
export const ShowVolumes = ({ id, type }: Props) => { export const ShowVolumes = ({ id, type }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canRead = permissions?.volume.read ?? false;
const canCreate = permissions?.volume.create ?? false;
const canDelete = permissions?.volume.delete ?? false;
if (!canRead) return null;
const queryMap = { 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: () => postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: 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] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const { mutateAsync: deleteVolume, isLoading: isRemoving } = const { mutateAsync: deleteVolume, isPending: isRemoving } =
api.mounts.remove.useMutation(); api.mounts.remove.useMutation();
return ( return (
<Card className="bg-background"> <Card className="bg-background">
<CardHeader className="flex flex-row justify-between flex-wrap gap-4"> <CardHeader className="flex flex-row justify-between flex-wrap gap-4">
@@ -50,7 +59,7 @@ export const ShowVolumes = ({ id, type }: Props) => {
</CardDescription> </CardDescription>
</div> </div>
{data && data?.mounts.length > 0 && ( {canCreate && data && data?.mounts.length > 0 && (
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}> <AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
Add Volume Add Volume
</AddVolumes> </AddVolumes>
@@ -63,9 +72,11 @@ export const ShowVolumes = ({ id, type }: Props) => {
<span className="text-base text-muted-foreground"> <span className="text-base text-muted-foreground">
No volumes/mounts configured No volumes/mounts configured
</span> </span>
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}> {canCreate && (
Add Volume <AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
</AddVolumes> Add Volume
</AddVolumes>
)}
</div> </div>
) : ( ) : (
<div className="flex flex-col pt-2 gap-4"> <div className="flex flex-col pt-2 gap-4">
@@ -130,38 +141,42 @@ export const ShowVolumes = ({ id, type }: Props) => {
</div> </div>
</div> </div>
<div className="flex flex-row gap-1"> <div className="flex flex-row gap-1">
<UpdateVolume {canCreate && (
mountId={mount.mountId} <UpdateVolume
type={mount.type} mountId={mount.mountId}
refetch={refetch} type={mount.type}
serviceType={type} refetch={refetch}
/> serviceType={type}
<DialogAction />
title="Delete Volume" )}
description="Are you sure you want to delete this volume?" {canDelete && (
type="destructive" <DialogAction
onClick={async () => { title="Delete Volume"
await deleteVolume({ description="Are you sure you want to delete this volume?"
mountId: mount.mountId, type="destructive"
}) onClick={async () => {
.then(() => { await deleteVolume({
refetch(); mountId: mount.mountId,
toast.success("Volume deleted successfully");
}) })
.catch(() => { .then(() => {
toast.error("Error deleting volume"); refetch();
}); toast.success("Volume deleted successfully");
}} })
> .catch(() => {
<Button toast.error("Error deleting volume");
variant="ghost" });
size="icon" }}
className="group hover:bg-red-500/10"
isLoading={isRemoving}
> >
<Trash2 className="size-4 text-primary group-hover:text-red-500" /> <Button
</Button> variant="ghost"
</DialogAction> size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon } from "lucide-react"; import { PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -67,13 +67,13 @@ interface Props {
refetch: () => void; refetch: () => void;
serviceType: serviceType:
| "application" | "application"
| "postgres" | "compose"
| "redis" | "libsql"
| "mongo"
| "redis"
| "mysql"
| "mariadb" | "mariadb"
| "compose"; | "mongo"
| "mysql"
| "postgres"
| "redis";
} }
export const UpdateVolume = ({ export const UpdateVolume = ({
@@ -93,7 +93,7 @@ export const UpdateVolume = ({
}, },
); );
const { mutateAsync, isLoading, error, isError } = const { mutateAsync, isPending, error, isError } =
api.mounts.update.useMutation(); api.mounts.update.useMutation();
const form = useForm<UpdateMount>({ const form = useForm<UpdateMount>({
@@ -187,7 +187,7 @@ export const UpdateVolume = ({
variant="ghost" variant="ghost"
size="icon" size="icon"
className="group hover:bg-blue-500/10 " className="group hover:bg-blue-500/10 "
isLoading={isLoading} isLoading={isPending}
> >
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" /> <PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button> </Button>
@@ -253,7 +253,7 @@ export const UpdateVolume = ({
control={form.control} control={form.control}
name="content" name="content"
render={({ field }) => ( render={({ field }) => (
<FormItem className="max-w-full max-w-[45rem]"> <FormItem className="w-full max-w-[45rem]">
<FormLabel>Content</FormLabel> <FormLabel>Content</FormLabel>
<FormControl> <FormControl>
<FormControl> <FormControl>
@@ -310,7 +310,7 @@ PORT=3000
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button
isLoading={isLoading} isLoading={isPending}
// form="hook-form-update-volume" // form="hook-form-update-volume"
type="submit" type="submit"
> >

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Cog } from "lucide-react"; import { Cog } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -74,12 +74,7 @@ const buildTypeDisplayMap: Record<BuildType, string> = {
const mySchema = z.discriminatedUnion("buildType", [ const mySchema = z.discriminatedUnion("buildType", [
z.object({ z.object({
buildType: z.literal(BuildType.dockerfile), buildType: z.literal(BuildType.dockerfile),
dockerfile: z dockerfile: z.string().nullable().default(""),
.string({
required_error: "Dockerfile path is required",
invalid_type_error: "Dockerfile path is required",
})
.min(1, "Dockerfile required"),
dockerContextPath: z.string().nullable().default(""), dockerContextPath: z.string().nullable().default(""),
dockerBuildStage: z.string().nullable().default(""), dockerBuildStage: z.string().nullable().default(""),
}), }),
@@ -168,14 +163,14 @@ const resetData = (data: ApplicationData): AddTemplate => {
}; };
export const ShowBuildChooseForm = ({ applicationId }: Props) => { export const ShowBuildChooseForm = ({ applicationId }: Props) => {
const { mutateAsync, isLoading } = const { mutateAsync, isPending } =
api.application.saveBuildType.useMutation(); api.application.saveBuildType.useMutation();
const { data, refetch } = api.application.one.useQuery( const { data, refetch } = api.application.one.useQuery(
{ applicationId }, { applicationId },
{ enabled: !!applicationId }, { enabled: !!applicationId },
); );
const form = useForm<AddTemplate>({ const form = useForm({
defaultValues: { defaultValues: {
buildType: BuildType.nixpacks, buildType: BuildType.nixpacks,
}, },
@@ -347,7 +342,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
<FormLabel>Docker File</FormLabel> <FormLabel>Docker File</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="Path of your docker file" placeholder="Path of your docker file (default: Dockerfile)"
{...field} {...field}
value={field.value ?? ""} value={field.value ?? ""}
/> />
@@ -533,7 +528,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
</> </>
)} )}
<div className="flex w-full justify-end"> <div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit"> <Button isLoading={isPending} type="submit">
Save Save
</Button> </Button>
</div> </div>

View File

@@ -20,7 +20,7 @@ interface Props {
} }
export const CancelQueues = ({ id, type }: Props) => { export const CancelQueues = ({ id, type }: Props) => {
const { mutateAsync, isLoading } = const { mutateAsync, isPending } =
type === "application" type === "application"
? api.application.cleanQueues.useMutation() ? api.application.cleanQueues.useMutation()
: api.compose.cleanQueues.useMutation(); : api.compose.cleanQueues.useMutation();
@@ -33,7 +33,7 @@ export const CancelQueues = ({ id, type }: Props) => {
return ( return (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="destructive" className="w-fit" isLoading={isLoading}> <Button variant="destructive" className="w-fit" isLoading={isPending}>
Cancel Queues Cancel Queues
<Ban className="size-4" /> <Ban className="size-4" />
</Button> </Button>

View File

@@ -21,7 +21,7 @@ interface Props {
export const ClearDeployments = ({ id, type }: Props) => { export const ClearDeployments = ({ id, type }: Props) => {
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync, isLoading } = const { mutateAsync, isPending } =
type === "application" type === "application"
? api.application.clearDeployments.useMutation() ? api.application.clearDeployments.useMutation()
: api.compose.clearDeployments.useMutation(); : api.compose.clearDeployments.useMutation();
@@ -29,7 +29,7 @@ export const ClearDeployments = ({ id, type }: Props) => {
return ( return (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="outline" className="w-fit" isLoading={isLoading}> <Button variant="outline" className="w-fit" isLoading={isPending}>
Clear deployments Clear deployments
<Paintbrush className="size-4" /> <Paintbrush className="size-4" />
</Button> </Button>

View File

@@ -20,7 +20,7 @@ interface Props {
} }
export const KillBuild = ({ id, type }: Props) => { export const KillBuild = ({ id, type }: Props) => {
const { mutateAsync, isLoading } = const { mutateAsync, isPending } =
type === "application" type === "application"
? api.application.killBuild.useMutation() ? api.application.killBuild.useMutation()
: api.compose.killBuild.useMutation(); : api.compose.killBuild.useMutation();
@@ -28,7 +28,7 @@ export const KillBuild = ({ id, type }: Props) => {
return ( return (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="outline" className="w-fit" isLoading={isLoading}> <Button variant="outline" className="w-fit" isLoading={isPending}>
Kill Build Kill Build
<Scissors className="size-4" /> <Scissors className="size-4" />
</Button> </Button>

View File

@@ -1,6 +1,7 @@
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { Check, Copy, Loader2 } from "lucide-react"; import { Check, Copy, Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { AnalyzeLogs } from "@/components/dashboard/docker/logs/analyze-logs";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
@@ -165,6 +166,7 @@ export const ShowDeployment = ({
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
)} )}
</Button> </Button>
<AnalyzeLogs logs={filteredLogs} context="build" />
{serverId && ( {serverId && (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@@ -194,13 +196,21 @@ export const ShowDeployment = ({
{" "} {" "}
{filteredLogs.length > 0 ? ( {filteredLogs.length > 0 ? (
filteredLogs.map((log: LogLine, index: number) => ( filteredLogs.map((log: LogLine, index: number) => (
<TerminalLine key={index} log={log} noTimestamp /> <TerminalLine
key={`${log.rawTimestamp ?? ""}-${index}`}
log={log}
noTimestamp
/>
)) ))
) : ( ) : (
<> <>
{optionalErrors.length > 0 ? ( {optionalErrors.length > 0 ? (
optionalErrors.map((log: LogLine, index: number) => ( optionalErrors.map((log: LogLine, index: number) => (
<TerminalLine key={`extra-${index}`} log={log} noTimestamp /> <TerminalLine
key={`extra-${log.rawTimestamp ?? ""}-${index}`}
log={log}
noTimestamp
/>
)) ))
) : ( ) : (
<div className="flex justify-center items-center h-full text-muted-foreground"> <div className="flex justify-center items-center h-full text-muted-foreground">

View File

@@ -1,7 +1,9 @@
import copy from "copy-to-clipboard";
import { import {
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
Clock, Clock,
Copy,
Loader2, Loader2,
RefreshCcw, RefreshCcw,
RocketIcon, RocketIcon,
@@ -61,7 +63,7 @@ export const ShowDeployments = ({
const [activeLog, setActiveLog] = useState< const [activeLog, setActiveLog] = useState<
RouterOutputs["deployment"]["all"][number] | null RouterOutputs["deployment"]["all"][number] | null
>(null); >(null);
const { data: deployments, isLoading: isLoadingDeployments } = const { data: deployments, isPending: isLoadingDeployments } =
api.deployment.allByType.useQuery( api.deployment.allByType.useQuery(
{ {
id, id,
@@ -75,21 +77,21 @@ export const ShowDeployments = ({
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const { mutateAsync: rollback, isLoading: isRollingBack } = const { mutateAsync: rollback, isPending: isRollingBack } =
api.rollback.rollback.useMutation(); api.rollback.rollback.useMutation();
const { mutateAsync: killProcess, isLoading: isKillingProcess } = const { mutateAsync: killProcess, isPending: isKillingProcess } =
api.deployment.killProcess.useMutation(); api.deployment.killProcess.useMutation();
const { mutateAsync: removeDeployment, isLoading: isRemovingDeployment } = const { mutateAsync: removeDeployment, isPending: isRemovingDeployment } =
api.deployment.removeDeployment.useMutation(); api.deployment.removeDeployment.useMutation();
// Cancel deployment mutations // Cancel deployment mutations
const { const {
mutateAsync: cancelApplicationDeployment, mutateAsync: cancelApplicationDeployment,
isLoading: isCancellingApp, isPending: isCancellingApp,
} = api.application.cancelDeployment.useMutation(); } = api.application.cancelDeployment.useMutation();
const { const {
mutateAsync: cancelComposeDeployment, mutateAsync: cancelComposeDeployment,
isLoading: isCancellingCompose, isPending: isCancellingCompose,
} = api.compose.cancelDeployment.useMutation(); } = api.compose.cancelDeployment.useMutation();
const [url, setUrl] = React.useState(""); const [url, setUrl] = React.useState("");
@@ -97,6 +99,12 @@ export const ShowDeployments = ({
new Set(), new Set(),
); );
const webhookUrl = useMemo(
() =>
`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`,
[url, refreshToken, type],
);
const MAX_DESCRIPTION_LENGTH = 200; const MAX_DESCRIPTION_LENGTH = 200;
const truncateDescription = (description: string): string => { const truncateDescription = (description: string): string => {
@@ -224,11 +232,27 @@ export const ShowDeployments = ({
<div className="flex flex-row items-center gap-2 flex-wrap"> <div className="flex flex-row items-center gap-2 flex-wrap">
<span>Webhook URL: </span> <span>Webhook URL: </span>
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<span className="break-all text-muted-foreground"> <Badge
{`${url}/api/deploy${ role="button"
type === "compose" ? "/compose" : "" tabIndex={0}
}/${refreshToken}`} aria-label="Copy webhook URL to clipboard"
</span> className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer whitespace-normal break-all"
variant="outline"
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
copy(webhookUrl);
toast.success("Copied to clipboard.");
}
}}
onClick={() => {
copy(webhookUrl);
toast.success("Copied to clipboard.");
}}
>
{webhookUrl}
<Copy className="h-4 w-4 ml-2" />
</Badge>
{(type === "application" || type === "compose") && ( {(type === "application" || type === "compose") && (
<RefreshToken id={id} type={type} /> <RefreshToken id={id} type={type} />
)} )}

View File

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

View File

@@ -1,11 +1,12 @@
import { zodResolver } from "@hookform/resolvers/zod"; 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 Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import z from "zod"; import z from "zod";
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -61,11 +62,14 @@ export const domain = z
.min(1, { message: "Port must be at least 1" }) .min(1, { message: "Port must be at least 1" })
.max(65535, { message: "Port must be 65535 or below" }) .max(65535, { message: "Port must be 65535 or below" })
.optional(), .optional(),
useCustomEntrypoint: z.boolean(),
customEntrypoint: z.string().optional(),
https: z.boolean().optional(), https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(), certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
customCertResolver: z.string().optional(), customCertResolver: z.string().optional(),
serviceName: z.string().optional(), serviceName: z.string().optional(),
domainType: z.enum(["application", "compose", "preview"]).optional(), domainType: z.enum(["application", "compose", "preview"]).optional(),
middlewares: z.array(z.string()).optional(),
}) })
.superRefine((input, ctx) => { .superRefine((input, ctx) => {
if (input.https && !input.certificateType) { if (input.https && !input.certificateType) {
@@ -114,6 +118,14 @@ export const domain = z
message: "Internal path must start with '/'", 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>; type Domain = z.infer<typeof domain>;
@@ -159,11 +171,11 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
}, },
); );
const { mutateAsync, isError, error, isLoading } = domainId const { mutateAsync, isError, error, isPending } = domainId
? api.domain.update.useMutation() ? api.domain.update.useMutation()
: api.domain.create.useMutation(); : api.domain.create.useMutation();
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } = const { mutateAsync: generateDomain, isPending: isLoadingGenerate } =
api.domain.generateDomain.useMutation(); api.domain.generateDomain.useMutation();
const { data: canGenerateTraefikMeDomains } = const { data: canGenerateTraefikMeDomains } =
@@ -196,16 +208,20 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
internalPath: undefined, internalPath: undefined,
stripPath: false, stripPath: false,
port: undefined, port: undefined,
useCustomEntrypoint: false,
customEntrypoint: undefined,
https: false, https: false,
certificateType: undefined, certificateType: undefined,
customCertResolver: undefined, customCertResolver: undefined,
serviceName: undefined, serviceName: undefined,
domainType: type, domainType: type,
middlewares: [],
}, },
mode: "onChange", mode: "onChange",
}); });
const certificateType = form.watch("certificateType"); const certificateType = form.watch("certificateType");
const useCustomEntrypoint = form.watch("useCustomEntrypoint");
const https = form.watch("https"); const https = form.watch("https");
const domainType = form.watch("domainType"); const domainType = form.watch("domainType");
const host = form.watch("host"); const host = form.watch("host");
@@ -220,10 +236,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
internalPath: data?.internalPath || undefined, internalPath: data?.internalPath || undefined,
stripPath: data?.stripPath || false, stripPath: data?.stripPath || false,
port: data?.port || undefined, port: data?.port || undefined,
useCustomEntrypoint: !!data.customEntrypoint,
customEntrypoint: data.customEntrypoint || undefined,
certificateType: data?.certificateType || undefined, certificateType: data?.certificateType || undefined,
customCertResolver: data?.customCertResolver || undefined, customCertResolver: data?.customCertResolver || undefined,
serviceName: data?.serviceName || undefined, serviceName: data?.serviceName || undefined,
domainType: data?.domainType || type, domainType: data?.domainType || type,
middlewares: data?.middlewares || [],
}); });
} }
@@ -234,13 +253,16 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
internalPath: undefined, internalPath: undefined,
stripPath: false, stripPath: false,
port: undefined, port: undefined,
useCustomEntrypoint: false,
customEntrypoint: undefined,
https: false, https: false,
certificateType: undefined, certificateType: undefined,
customCertResolver: undefined, customCertResolver: undefined,
domainType: type, domainType: type,
middlewares: [],
}); });
} }
}, [form, data, isLoading, domainId]); }, [form, data, isPending, domainId]);
// Separate effect for handling custom cert resolver validation // Separate effect for handling custom cert resolver validation
useEffect(() => { useEffect(() => {
@@ -268,6 +290,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
composeId: id, composeId: id,
}), }),
...data, ...data,
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
}) })
.then(async () => { .then(async () => {
toast.success(dictionary.success); 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 <FormField
control={form.control} control={form.control}
name="https" name="https"
@@ -725,12 +797,94 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
)} )}
</> </>
)} )}
<FormField
control={form.control}
name="middlewares"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>Middlewares</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p>
Add Traefik middleware references. Middlewares
must be defined in your Traefik configuration.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((name, index) => (
<Badge key={index} variant="secondary">
{name}
<X
className="ml-1 size-3 cursor-pointer"
onClick={() => {
const newMiddlewares = [...(field.value || [])];
newMiddlewares.splice(index, 1);
form.setValue("middlewares", newMiddlewares);
}}
/>
</Badge>
))}
</div>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="e.g., rate-limit@file, auth@file"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const value = input.value.trim();
if (value && !field.value?.includes(value)) {
form.setValue("middlewares", [
...(field.value || []),
value,
]);
input.value = "";
}
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="e.g., rate-limit@file, auth@file"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value && !field.value?.includes(value)) {
form.setValue("middlewares", [
...(field.value || []),
value,
]);
input.value = "";
}
}}
>
Add
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
</div> </div>
</form> </form>
<DialogFooter> <DialogFooter>
<Button isLoading={isLoading} form="hook-form" type="submit"> <Button isLoading={isPending} form="hook-form" type="submit">
{dictionary.submit} {dictionary.submit}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -1,8 +1,22 @@
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import { import {
CheckCircle2, CheckCircle2,
ChevronDown,
ExternalLink, ExternalLink,
GlobeIcon, GlobeIcon,
InfoIcon, InfoIcon,
LayoutGrid,
LayoutList,
Loader2, Loader2,
PenBoxIcon, PenBoxIcon,
RefreshCw, RefreshCw,
@@ -23,6 +37,21 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } 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 { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -30,6 +59,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { createColumns } from "./columns";
import { DnsHelperModal } from "./dns-helper-modal"; import { DnsHelperModal } from "./dns-helper-modal";
import { AddDomain } from "./handle-domain"; import { AddDomain } from "./handle-domain";
@@ -50,6 +80,9 @@ interface Props {
} }
export const ShowDomains = ({ id, type }: Props) => { export const ShowDomains = ({ id, type }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canCreateDomain = permissions?.domain.create ?? false;
const canDeleteDomain = permissions?.domain.delete ?? false;
const { data: application } = const { data: application } =
type === "application" type === "application"
? api.application.one.useQuery( ? api.application.one.useQuery(
@@ -71,6 +104,19 @@ export const ShowDomains = ({ id, type }: Props) => {
const [validationStates, setValidationStates] = useState<ValidationStates>( 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 { data: ip } = api.settings.getIp.useQuery();
const { const {
@@ -97,9 +143,19 @@ export const ShowDomains = ({ id, type }: Props) => {
const { mutateAsync: validateDomain } = const { mutateAsync: validateDomain } =
api.domain.validateDomain.useMutation(); api.domain.validateDomain.useMutation();
const { mutateAsync: deleteDomain, isLoading: isRemoving } = const { mutateAsync: deleteDomain, isPending: isRemoving } =
api.domain.delete.useMutation(); 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) => { const handleValidateDomain = async (host: string) => {
setValidationStates((prev) => ({ setValidationStates((prev) => ({
...prev, ...prev,
@@ -137,6 +193,37 @@ export const ShowDomains = ({ id, type }: Props) => {
} }
}; };
const columns = createColumns({
id,
type,
validationStates,
handleValidateDomain,
handleDeleteDomain,
isDeleting: isRemoving,
serverIp: application?.server?.ipAddress?.toString() || ip?.toString(),
canCreateDomain,
canDeleteDomain,
});
const table = useReactTable({
data: data ?? [],
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
return ( return (
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
<Card className="bg-background"> <Card className="bg-background">
@@ -148,13 +235,32 @@ export const ShowDomains = ({ id, type }: Props) => {
</CardDescription> </CardDescription>
</div> </div>
<div className="flex flex-row gap-4 flex-wrap"> <div className="flex flex-row gap-2 flex-wrap">
{data && data?.length > 0 && ( {data && data?.length > 0 && (
<AddDomain id={id} type={type}> <>
<Button> <Button
<GlobeIcon className="size-4" /> Add Domain 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> </Button>
</AddDomain> {canCreateDomain && (
<AddDomain id={id} type={type}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
</AddDomain>
)}
</>
)} )}
</div> </div>
</CardHeader> </CardHeader>
@@ -173,13 +279,131 @@ export const ShowDomains = ({ id, type }: Props) => {
To access the application it is required to set at least 1 To access the application it is required to set at least 1
domain domain
</span> </span>
<div className="flex flex-row gap-4 flex-wrap"> {canCreateDomain && (
<AddDomain id={id} type={type}> <div className="flex flex-row gap-4 flex-wrap">
<Button> <AddDomain id={id} type={type}>
<GlobeIcon className="size-4" /> Add Domain <Button>
</Button> <GlobeIcon className="size-4" /> Add Domain
</AddDomain> </Button>
</AddDomain>
</div>
)}
</div>
) : viewMode === "table" ? (
<div className="flex flex-col gap-4 w-full">
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by host..."
value={
(table.getColumn("host")?.getFilterValue() as string) ?? ""
}
onChange={(event) =>
table.getColumn("host")?.setFilterValue(event.target.value)
}
className="md:max-w-sm"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="sm:ml-auto max-sm:w-full"
>
Columns <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
<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>
) : ( ) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] "> <div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
@@ -214,47 +438,51 @@ export const ShowDomains = ({ id, type }: Props) => {
} }
/> />
)} )}
<AddDomain {canCreateDomain && (
id={id} <AddDomain
type={type} id={id}
domainId={item.domainId} type={type}
> domainId={item.domainId}
<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
</Button> variant="ghost"
</AddDomain> size="icon"
<DialogAction className="group hover:bg-blue-500/10"
title="Delete Domain" >
description="Are you sure you want to delete this domain?" <PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
type="destructive" </Button>
onClick={async () => { </AddDomain>
await deleteDomain({ )}
domainId: item.domainId, {canDeleteDomain && (
}) <DialogAction
.then((_data) => { title="Delete Domain"
refetch(); description="Are you sure you want to delete this domain?"
toast.success( type="destructive"
"Domain deleted successfully", onClick={async () => {
); await deleteDomain({
domainId: item.domainId,
}) })
.catch(() => { .then((_data) => {
toast.error("Error deleting domain"); refetch();
}); toast.success(
}} "Domain deleted successfully",
> );
<Button })
variant="ghost" .catch(() => {
size="icon" toast.error("Error deleting domain");
className="group hover:bg-red-500/10" });
isLoading={isRemoving} }}
> >
<Trash2 className="size-4 text-primary group-hover:text-red-500" /> <Button
</Button> variant="ghost"
</DialogAction> size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div> </div>
</div> </div>
<div className="w-full break-all"> <div className="w-full break-all">
@@ -332,6 +560,22 @@ export const ShowDomains = ({ id, type }: Props) => {
</TooltipProvider> </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> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { EyeIcon, EyeOffIcon } from "lucide-react"; import { EyeIcon, EyeOffIcon } from "lucide-react";
import { type CSSProperties, useEffect, useState } from "react"; import { type CSSProperties, useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -36,16 +36,19 @@ interface Props {
} }
export const ShowEnvironment = ({ id, type }: Props) => { export const ShowEnvironment = ({ id, type }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canWrite = permissions?.envVars.write ?? false;
const queryMap = { const queryMap = {
postgres: () => compose: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }), libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () => mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
compose: () => mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
api.compose.one.useQuery({ composeId: 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] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
@@ -53,16 +56,17 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const [isEnvVisible, setIsEnvVisible] = useState(true); const [isEnvVisible, setIsEnvVisible] = useState(true);
const mutationMap = { const mutationMap = {
postgres: () => api.postgres.update.useMutation(), compose: () => api.compose.saveEnvironment.useMutation(),
redis: () => api.redis.update.useMutation(), libsql: () => api.libsql.saveEnvironment.useMutation(),
mysql: () => api.mysql.update.useMutation(), mariadb: () => api.mariadb.saveEnvironment.useMutation(),
mariadb: () => api.mariadb.update.useMutation(), mongo: () => api.mongo.saveEnvironment.useMutation(),
mongo: () => api.mongo.update.useMutation(), mysql: () => api.mysql.saveEnvironment.useMutation(),
compose: () => api.compose.update.useMutation(), postgres: () => api.postgres.saveEnvironment.useMutation(),
redis: () => api.redis.saveEnvironment.useMutation(),
}; };
const { mutateAsync, isLoading } = mutationMap[type] const { mutateAsync, isPending } = mutationMap[type]
? mutationMap[type]() ? mutationMap[type]()
: api.mongo.update.useMutation(); : api.mongo.saveEnvironment.useMutation();
const form = useForm<EnvironmentSchema>({ const form = useForm<EnvironmentSchema>({
defaultValues: { defaultValues: {
@@ -85,12 +89,13 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const onSubmit = async (formData: EnvironmentSchema) => { const onSubmit = async (formData: EnvironmentSchema) => {
mutateAsync({ mutateAsync({
composeId: id || "",
libsqlId: id || "",
mariadbId: id || "",
mongoId: id || "", mongoId: id || "",
mysqlId: id || "",
postgresId: id || "", postgresId: id || "",
redisId: id || "", redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
composeId: id || "",
env: formData.environment, env: formData.environment,
}) })
.then(async () => { .then(async () => {
@@ -111,7 +116,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S // Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) { if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
e.preventDefault(); e.preventDefault();
form.handleSubmit(onSubmit)(); form.handleSubmit(onSubmit)();
} }
@@ -121,7 +126,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
return () => { return () => {
document.removeEventListener("keydown", handleKeyDown); document.removeEventListener("keydown", handleKeyDown);
}; };
}, [form, onSubmit, isLoading]); }, [form, onSubmit, isPending]);
return ( return (
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
@@ -185,25 +190,27 @@ PORT=3000
)} )}
/> />
<div className="flex flex-row justify-end gap-2"> {canWrite && (
{hasChanges && ( <div className="flex flex-row justify-end gap-2">
{hasChanges && (
<Button
type="button"
variant="outline"
onClick={handleCancel}
>
Cancel
</Button>
)}
<Button <Button
type="button" isLoading={isPending}
variant="outline" className="w-fit"
onClick={handleCancel} type="submit"
disabled={!hasChanges}
> >
Cancel Save
</Button> </Button>
)} </div>
<Button )}
isLoading={isLoading}
className="w-fit"
type="submit"
disabled={!hasChanges}
>
Save
</Button>
</div>
</form> </form>
</Form> </Form>
</CardContent> </CardContent>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -31,7 +31,9 @@ interface Props {
} }
export const ShowEnvironment = ({ applicationId }: Props) => { export const ShowEnvironment = ({ applicationId }: Props) => {
const { mutateAsync, isLoading } = const { data: permissions } = api.user.getPermissions.useQuery();
const canWrite = permissions?.envVars.write ?? false;
const { mutateAsync, isPending } =
api.application.saveEnvironment.useMutation(); api.application.saveEnvironment.useMutation();
const { data, refetch } = api.application.one.useQuery( const { data, refetch } = api.application.one.useQuery(
@@ -104,7 +106,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S // Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) { if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
e.preventDefault(); e.preventDefault();
form.handleSubmit(onSubmit)(); form.handleSubmit(onSubmit)();
} }
@@ -114,7 +116,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
return () => { return () => {
document.removeEventListener("keydown", handleKeyDown); document.removeEventListener("keydown", handleKeyDown);
}; };
}, [form, onSubmit, isLoading]); }, [form, onSubmit, isPending]);
return ( return (
<Card className="bg-background px-6 pb-6"> <Card className="bg-background px-6 pb-6">
@@ -201,27 +203,30 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
<Switch <Switch
checked={field.value} checked={field.value}
onCheckedChange={field.onChange} onCheckedChange={field.onChange}
disabled={!canWrite}
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
/> />
)} )}
<div className="flex flex-row justify-end gap-2"> {canWrite && (
{hasChanges && ( <div className="flex flex-row justify-end gap-2">
<Button type="button" variant="outline" onClick={handleCancel}> {hasChanges && (
Cancel <Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
)}
<Button
isLoading={isPending}
className="w-fit"
type="submit"
disabled={!hasChanges}
>
Save
</Button> </Button>
)} </div>
<Button )}
isLoading={isLoading}
className="w-fit"
type="submit"
disabled={!hasChanges}
>
Save
</Button>
</div>
</form> </form>
</Form> </Form>
</Card> </Card>

View File

@@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -74,10 +74,10 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
api.bitbucket.bitbucketProviders.useQuery(); api.bitbucket.bitbucketProviders.useQuery();
const { data, refetch } = api.application.one.useQuery({ applicationId }); const { data, refetch } = api.application.one.useQuery({ applicationId });
const { mutateAsync, isLoading: isSavingBitbucketProvider } = const { mutateAsync, isPending: isSavingBitbucketProvider } =
api.application.saveBitbucketProvider.useMutation(); api.application.saveBitbucketProvider.useMutation();
const form = useForm<BitbucketProvider>({ const form = useForm({
defaultValues: { defaultValues: {
buildPath: "/", buildPath: "/",
repository: { repository: {
@@ -333,7 +333,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
{status === "loading" && fetchStatus === "fetching" {status === "pending" && fetchStatus === "fetching"
? "Loading...." ? "Loading...."
: field.value : field.value
? branches?.find( ? branches?.find(
@@ -350,7 +350,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
placeholder="Search branch..." placeholder="Search branch..."
className="h-9" className="h-9"
/> />
{status === "loading" && fetchStatus === "fetching" && ( {status === "pending" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground"> <span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches.... Loading Branches....
</span> </span>
@@ -416,10 +416,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<FormLabel>Watch Paths</FormLabel> <FormLabel>Watch Paths</FormLabel>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger asChild>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold"> <HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
?
</div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p> <p>

View File

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

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { TrashIcon } from "lucide-react"; import { TrashIcon } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -24,10 +24,10 @@ interface Props {
export const SaveDragNDrop = ({ applicationId }: Props) => { export const SaveDragNDrop = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery({ applicationId }); const { data, refetch } = api.application.one.useQuery({ applicationId });
const { mutateAsync, isLoading } = const { mutateAsync, isPending } =
api.application.dropDeployment.useMutation(); api.application.dropDeployment.useMutation();
const form = useForm<UploadFile>({ const form = useForm({
defaultValues: {}, defaultValues: {},
resolver: zodResolver(uploadFileSchema), resolver: zodResolver(uploadFileSchema),
}); });
@@ -129,8 +129,8 @@ export const SaveDragNDrop = ({ applicationId }: Props) => {
<Button <Button
type="submit" type="submit"
className="w-fit" className="w-fit"
isLoading={isLoading} isLoading={isPending}
disabled={!zip || isLoading} disabled={!zip || isPending}
> >
Deploy{" "} Deploy{" "}
</Button> </Button>

View File

@@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { KeyRoundIcon, LockIcon, X } from "lucide-react"; import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -55,13 +55,13 @@ interface Props {
export const SaveGitProvider = ({ applicationId }: Props) => { export const SaveGitProvider = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery({ applicationId }); 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 router = useRouter();
const { mutateAsync, isLoading } = const { mutateAsync, isPending } =
api.application.saveGitProvider.useMutation(); api.application.saveGitProvider.useMutation();
const form = useForm<GitProvider>({ const form = useForm({
defaultValues: { defaultValues: {
branch: "", branch: "",
buildPath: "/", buildPath: "/",
@@ -228,10 +228,8 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
<FormLabel>Watch Paths</FormLabel> <FormLabel>Watch Paths</FormLabel>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger asChild>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold"> <HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
?
</div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="max-w-[300px]"> <TooltipContent className="max-w-[300px]">
<p> <p>
@@ -317,7 +315,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
</div> </div>
<div className="flex flex-row justify-end"> <div className="flex flex-row justify-end">
<Button type="submit" className="w-fit" isLoading={isLoading}> <Button type="submit" className="w-fit" isLoading={isPending}>
Save Save
</Button> </Button>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -88,10 +88,10 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery(); const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data, refetch } = api.application.one.useQuery({ applicationId }); const { data, refetch } = api.application.one.useQuery({ applicationId });
const { mutateAsync, isLoading: isSavingGiteaProvider } = const { mutateAsync, isPending: isSavingGiteaProvider } =
api.application.saveGiteaProvider.useMutation(); api.application.saveGiteaProvider.useMutation();
const form = useForm<GiteaProvider>({ const form = useForm({
defaultValues: { defaultValues: {
buildPath: "/", buildPath: "/",
repository: { repository: {
@@ -353,7 +353,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
{status === "loading" && fetchStatus === "fetching" {status === "pending" && fetchStatus === "fetching"
? "Loading...." ? "Loading...."
: field.value : field.value
? branches?.find( ? branches?.find(
@@ -371,7 +371,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
placeholder="Search branch..." placeholder="Search branch..."
className="h-9" className="h-9"
/> />
{status === "loading" && fetchStatus === "fetching" && ( {status === "pending" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground"> <span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches.... Loading Branches....
</span> </span>
@@ -463,7 +463,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<X <X
className="size-3 cursor-pointer hover:text-destructive" className="size-3 cursor-pointer hover:text-destructive"
onClick={() => { onClick={() => {
const newPaths = [...field.value]; const newPaths = [...(field.value || [])];
newPaths.splice(index, 1); newPaths.splice(index, 1);
field.onChange(newPaths); field.onChange(newPaths);
}} }}
@@ -481,7 +481,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
const input = e.currentTarget; const input = e.currentTarget;
const path = input.value.trim(); const path = input.value.trim();
if (path) { if (path) {
field.onChange([...field.value, path]); field.onChange([...(field.value || []), path]);
input.value = ""; input.value = "";
} }
} }
@@ -498,7 +498,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
) as HTMLInputElement; ) as HTMLInputElement;
const path = input.value.trim(); const path = input.value.trim();
if (path) { if (path) {
field.onChange([...field.value, path]); field.onChange([...(field.value || []), path]);
input.value = ""; input.value = "";
} }
}} }}

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -72,10 +72,10 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
const { data: githubProviders } = api.github.githubProviders.useQuery(); const { data: githubProviders } = api.github.githubProviders.useQuery();
const { data, refetch } = api.application.one.useQuery({ applicationId }); const { data, refetch } = api.application.one.useQuery({ applicationId });
const { mutateAsync, isLoading: isSavingGithubProvider } = const { mutateAsync, isPending: isSavingGithubProvider } =
api.application.saveGithubProvider.useMutation(); api.application.saveGithubProvider.useMutation();
const form = useForm<GithubProvider>({ const form = useForm({
defaultValues: { defaultValues: {
buildPath: "/", buildPath: "/",
repository: { repository: {
@@ -94,7 +94,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
const githubId = form.watch("githubId"); const githubId = form.watch("githubId");
const triggerType = form.watch("triggerType"); const triggerType = form.watch("triggerType");
const { data: repositories, isLoading: isLoadingRepositories } = const { data: repositories, isPending: isLoadingRepositories } =
api.github.getGithubRepositories.useQuery( api.github.getGithubRepositories.useQuery(
{ {
githubId, githubId,
@@ -320,7 +320,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
{status === "loading" && fetchStatus === "fetching" {status === "pending" && fetchStatus === "fetching"
? "Loading...." ? "Loading...."
: field.value : field.value
? branches?.find( ? branches?.find(
@@ -337,7 +337,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
placeholder="Search branch..." placeholder="Search branch..."
className="h-9" className="h-9"
/> />
{status === "loading" && fetchStatus === "fetching" && ( {status === "pending" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground"> <span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches.... Loading Branches....
</span> </span>
@@ -459,7 +459,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<div className="flex flex-wrap gap-2 mb-2"> <div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => ( {field.value?.map((path, index) => (
<Badge <Badge
key={index} key={`${path}-${index}`}
variant="secondary" variant="secondary"
className="flex items-center gap-1" className="flex items-center gap-1"
> >

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
@@ -74,10 +74,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery(); const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
const { data, refetch } = api.application.one.useQuery({ applicationId }); const { data, refetch } = api.application.one.useQuery({ applicationId });
const { mutateAsync, isLoading: isSavingGitlabProvider } = const { mutateAsync, isPending: isSavingGitlabProvider } =
api.application.saveGitlabProvider.useMutation(); api.application.saveGitlabProvider.useMutation();
const form = useForm<GitlabProvider>({ const form = useForm({
defaultValues: { defaultValues: {
buildPath: "/", buildPath: "/",
repository: { repository: {
@@ -351,7 +351,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
{status === "loading" && fetchStatus === "fetching" {status === "pending" && fetchStatus === "fetching"
? "Loading...." ? "Loading...."
: field.value : field.value
? branches?.find( ? branches?.find(
@@ -368,7 +368,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
placeholder="Search branch..." placeholder="Search branch..."
className="h-9" className="h-9"
/> />
{status === "loading" && fetchStatus === "fetching" && ( {status === "pending" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground"> <span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches.... Loading Branches....
</span> </span>
@@ -448,7 +448,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<div className="flex flex-wrap gap-2 mb-2"> <div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => ( {field.value?.map((path, index) => (
<Badge <Badge
key={index} key={`${path}-${index}`}
variant="secondary" variant="secondary"
className="flex items-center gap-1" className="flex items-center gap-1"
> >

View File

@@ -36,13 +36,13 @@ interface Props {
} }
export const ShowProviderForm = ({ applicationId }: Props) => { export const ShowProviderForm = ({ applicationId }: Props) => {
const { data: githubProviders, isLoading: isLoadingGithub } = const { data: githubProviders, isPending: isLoadingGithub } =
api.github.githubProviders.useQuery(); api.github.githubProviders.useQuery();
const { data: gitlabProviders, isLoading: isLoadingGitlab } = const { data: gitlabProviders, isPending: isLoadingGitlab } =
api.gitlab.gitlabProviders.useQuery(); api.gitlab.gitlabProviders.useQuery();
const { data: bitbucketProviders, isLoading: isLoadingBitbucket } = const { data: bitbucketProviders, isPending: isLoadingBitbucket } =
api.bitbucket.bitbucketProviders.useQuery(); api.bitbucket.bitbucketProviders.useQuery();
const { data: giteaProviders, isLoading: isLoadingGitea } = const { data: giteaProviders, isPending: isLoadingGitea } =
api.gitea.giteaProviders.useQuery(); api.gitea.giteaProviders.useQuery();
const { data: application, refetch } = api.application.one.useQuery({ const { data: application, refetch } = api.application.one.useQuery({

View File

@@ -30,6 +30,9 @@ interface Props {
export const ShowGeneralApplication = ({ applicationId }: Props) => { export const ShowGeneralApplication = ({ applicationId }: Props) => {
const router = useRouter(); const router = useRouter();
const { data: permissions } = api.user.getPermissions.useQuery();
const canDeploy = permissions?.deployment.create ?? false;
const canUpdateService = permissions?.service.create ?? false;
const { data, refetch } = api.application.one.useQuery( const { data, refetch } = api.application.one.useQuery(
{ {
applicationId, applicationId,
@@ -37,14 +40,14 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
{ enabled: !!applicationId }, { enabled: !!applicationId },
); );
const { mutateAsync: update } = api.application.update.useMutation(); const { mutateAsync: update } = api.application.update.useMutation();
const { mutateAsync: start, isLoading: isStarting } = const { mutateAsync: start, isPending: isStarting } =
api.application.start.useMutation(); api.application.start.useMutation();
const { mutateAsync: stop, isLoading: isStopping } = const { mutateAsync: stop, isPending: isStopping } =
api.application.stop.useMutation(); api.application.stop.useMutation();
const { mutateAsync: deploy } = api.application.deploy.useMutation(); const { mutateAsync: deploy } = api.application.deploy.useMutation();
const { mutateAsync: reload, isLoading: isReloading } = const { mutateAsync: reload, isPending: isReloading } =
api.application.reload.useMutation(); api.application.reload.useMutation();
const { mutateAsync: redeploy } = api.application.redeploy.useMutation(); const { mutateAsync: redeploy } = api.application.redeploy.useMutation();
@@ -57,128 +60,135 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap"> <CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0} disableHoverableContent={false}> <TooltipProvider delayDuration={0} disableHoverableContent={false}>
<DialogAction {canDeploy && (
title="Deploy Application" <DialogAction
description="Are you sure you want to deploy this application?" title="Deploy Application"
type="default" description="Are you sure you want to deploy this application?"
onClick={async () => { type="default"
await deploy({ onClick={async () => {
applicationId: applicationId, await deploy({
}) applicationId: applicationId,
.then(() => {
toast.success("Application deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
);
}) })
.catch(() => { .then(() => {
toast.error("Error deploying application"); toast.success("Application deployed successfully");
}); refetch();
}} router.push(
> `/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
<Button );
variant="default" })
isLoading={data?.applicationStatus === "running"} .catch(() => {
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2" toast.error("Error deploying application");
});
}}
> >
<Tooltip> <Button
<TooltipTrigger asChild> variant="default"
<div className="flex items-center"> isLoading={data?.applicationStatus === "running"}
<Rocket className="size-4 mr-1" /> className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
Deploy >
</div> <Tooltip>
</TooltipTrigger> <TooltipTrigger asChild>
<TooltipPrimitive.Portal> <div className="flex items-center">
<TooltipContent sideOffset={5} className="z-[60]"> <Rocket className="size-4 mr-1" />
<p> Deploy
Downloads the source code and performs a complete build </div>
</p> </TooltipTrigger>
</TooltipContent> <TooltipPrimitive.Portal>
</TooltipPrimitive.Portal> <TooltipContent sideOffset={5} className="z-[60]">
</Tooltip> <p>
</Button> Downloads the source code and performs a complete
</DialogAction> build
<DialogAction </p>
title="Reload Application" </TooltipContent>
description="Are you sure you want to reload this application?" </TooltipPrimitive.Portal>
type="default" </Tooltip>
onClick={async () => { </Button>
await reload({ </DialogAction>
applicationId: applicationId, )}
appName: data?.appName || "", {canDeploy && (
}) <DialogAction
.then(() => { title="Reload Application"
toast.success("Application reloaded successfully"); description="Are you sure you want to reload this application?"
refetch(); type="default"
onClick={async () => {
await reload({
applicationId: applicationId,
appName: data?.appName || "",
}) })
.catch(() => { .then(() => {
toast.error("Error reloading application"); toast.success("Application reloaded successfully");
}); refetch();
}} })
> .catch(() => {
<Button toast.error("Error reloading application");
variant="secondary" });
isLoading={isReloading} }}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Tooltip> <Button
<TooltipTrigger asChild> variant="secondary"
<div className="flex items-center"> isLoading={isReloading}
<RefreshCcw className="size-4 mr-1" /> className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
Reload >
</div> <Tooltip>
</TooltipTrigger> <TooltipTrigger asChild>
<TooltipPrimitive.Portal> <div className="flex items-center">
<TooltipContent sideOffset={5} className="z-[60]"> <RefreshCcw className="size-4 mr-1" />
<p>Reload the application without rebuilding it</p> Reload
</TooltipContent> </div>
</TooltipPrimitive.Portal> </TooltipTrigger>
</Tooltip> <TooltipPrimitive.Portal>
</Button> <TooltipContent sideOffset={5} className="z-[60]">
</DialogAction> <p>Reload the application without rebuilding it</p>
<DialogAction </TooltipContent>
title="Rebuild Application" </TooltipPrimitive.Portal>
description="Are you sure you want to rebuild this application?" </Tooltip>
type="default" </Button>
onClick={async () => { </DialogAction>
await redeploy({ )}
applicationId: applicationId, {canDeploy && (
}) <DialogAction
.then(() => { title="Rebuild Application"
toast.success("Application rebuilt successfully"); description="Are you sure you want to rebuild this application?"
refetch(); type="default"
onClick={async () => {
await redeploy({
applicationId: applicationId,
}) })
.catch(() => { .then(() => {
toast.error("Error rebuilding application"); toast.success("Application rebuilt successfully");
}); refetch();
}} })
> .catch(() => {
<Button toast.error("Error rebuilding application");
variant="secondary" });
isLoading={data?.applicationStatus === "running"} }}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Tooltip> <Button
<TooltipTrigger asChild> variant="secondary"
<div className="flex items-center"> isLoading={data?.applicationStatus === "running"}
<Hammer className="size-4 mr-1" /> className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
Rebuild >
</div> <Tooltip>
</TooltipTrigger> <TooltipTrigger asChild>
<TooltipPrimitive.Portal> <div className="flex items-center">
<TooltipContent sideOffset={5} className="z-[60]"> <Hammer className="size-4 mr-1" />
<p> Rebuild
Only rebuilds the application without downloading new </div>
code </TooltipTrigger>
</p> <TooltipPrimitive.Portal>
</TooltipContent> <TooltipContent sideOffset={5} className="z-[60]">
</TooltipPrimitive.Portal> <p>
</Tooltip> Only rebuilds the application without downloading new
</Button> code
</DialogAction> </p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{data?.applicationStatus === "idle" ? ( {canDeploy && data?.applicationStatus === "idle" ? (
<DialogAction <DialogAction
title="Start Application" title="Start Application"
description="Are you sure you want to start this application?" description="Are you sure you want to start this application?"
@@ -219,7 +229,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
</Tooltip> </Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
) : ( ) : canDeploy ? (
<DialogAction <DialogAction
title="Stop Application" title="Stop Application"
description="Are you sure you want to stop this application?" description="Are you sure you want to stop this application?"
@@ -256,7 +266,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
</Tooltip> </Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
)} ) : null}
</TooltipProvider> </TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}
@@ -270,49 +280,53 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
Open Terminal Open Terminal
</Button> </Button>
</DockerTerminalModal> </DockerTerminalModal>
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border"> {canUpdateService && (
<span className="text-sm font-medium">Autodeploy</span> <div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<Switch <span className="text-sm font-medium">Autodeploy</span>
aria-label="Toggle autodeploy" <Switch
checked={data?.autoDeploy || false} aria-label="Toggle autodeploy"
onCheckedChange={async (enabled) => { checked={data?.autoDeploy || false}
await update({ onCheckedChange={async (enabled) => {
applicationId, await update({
autoDeploy: enabled, applicationId,
}) autoDeploy: enabled,
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
}) })
.catch(() => { .then(async () => {
toast.error("Error updating Auto Deploy"); toast.success("Auto Deploy Updated");
}); await refetch();
}} })
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" .catch(() => {
/> toast.error("Error updating Auto Deploy");
</div> });
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
</div>
)}
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border"> {canUpdateService && (
<span className="text-sm font-medium">Clean Cache</span> <div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<Switch <span className="text-sm font-medium">Clean Cache</span>
aria-label="Toggle clean cache" <Switch
checked={data?.cleanCache || false} aria-label="Toggle clean cache"
onCheckedChange={async (enabled) => { checked={data?.cleanCache || false}
await update({ onCheckedChange={async (enabled) => {
applicationId, await update({
cleanCache: enabled, applicationId,
}) cleanCache: enabled,
.then(async () => {
toast.success("Clean Cache Updated");
await refetch();
}) })
.catch(() => { .then(async () => {
toast.error("Error updating Clean Cache"); toast.success("Clean Cache Updated");
}); await refetch();
}} })
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" .catch(() => {
/> toast.error("Error updating Clean Cache");
</div> });
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
<ShowProviderForm applicationId={applicationId} /> <ShowProviderForm applicationId={applicationId} />

View File

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

View File

@@ -56,7 +56,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
const [containerId, setContainerId] = useState<string | undefined>(); const [containerId, setContainerId] = useState<string | undefined>();
const [option, setOption] = useState<"swarm" | "native">("native"); const [option, setOption] = useState<"swarm" | "native">("native");
const { data: services, isLoading: servicesLoading } = const { data: services, isPending: servicesLoading } =
api.docker.getServiceContainersByAppName.useQuery( api.docker.getServiceContainersByAppName.useQuery(
{ {
appName, appName,
@@ -67,7 +67,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
}, },
); );
const { data: containers, isLoading: containersLoading } = const { data: containers, isPending: containersLoading } =
api.docker.getContainersByAppNameMatch.useQuery( api.docker.getContainersByAppNameMatch.useQuery(
{ {
appName, appName,
@@ -91,7 +91,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
}, [option, services, containers]); }, [option, services, containers]);
const isLoading = option === "native" ? containersLoading : servicesLoading; const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLenght = const containersLength =
option === "native" ? containers?.length : services?.length; option === "native" ? containers?.length : services?.length;
return ( return (
@@ -167,7 +167,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
</> </>
)} )}
<SelectLabel>Containers ({containersLenght})</SelectLabel> <SelectLabel>Containers ({containersLength})</SelectLabel>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>

View File

@@ -28,7 +28,7 @@ export const EditPatchDialog = ({
type, type,
onSuccess, onSuccess,
}: Props) => { }: Props) => {
const { data: patch, isLoading: isPatchLoading } = api.patch.one.useQuery( const { data: patch, isPending: isPatchLoading } = api.patch.one.useQuery(
{ patchId }, { patchId },
{ enabled: !!patchId }, { enabled: !!patchId },
); );
@@ -89,7 +89,7 @@ export const EditPatchDialog = ({
<DialogClose asChild> <DialogClose asChild>
<Button variant="outline">Cancel</Button> <Button variant="outline">Cancel</Button>
</DialogClose> </DialogClose>
<Button onClick={handleSave} isLoading={updatePatch.isLoading}> <Button onClick={handleSave} isLoading={updatePatch.isPending}>
{updatePatch.isPending && ( {updatePatch.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
)} )}

View File

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

View File

@@ -45,7 +45,7 @@ export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => {
); );
const utils = api.useUtils(); const utils = api.useUtils();
const { data: directories, isLoading: isDirLoading } = const { data: directories, isPending: isDirLoading } =
api.patch.readRepoDirectories.useQuery( api.patch.readRepoDirectories.useQuery(
{ id: id, type, repoPath }, { id: id, type, repoPath },
{ enabled: !!repoPath }, { enabled: !!repoPath },
@@ -56,10 +56,10 @@ export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => {
{ enabled: !!id }, { enabled: !!id },
); );
const { mutateAsync: saveAsPatch, isLoading: isSavingPatch } = const { mutateAsync: saveAsPatch, isPending: isSavingPatch } =
api.patch.saveFileAsPatch.useMutation(); api.patch.saveFileAsPatch.useMutation();
const { mutateAsync: markForDeletion, isLoading: isMarkingDeletion } = const { mutateAsync: markForDeletion, isPending: isMarkingDeletion } =
api.patch.markFileForDeletion.useMutation(); api.patch.markFileForDeletion.useMutation();
const updatePatch = api.patch.update.useMutation(); const updatePatch = api.patch.update.useMutation();

View File

@@ -1,6 +1,7 @@
import { File, FilePlus2, Loader2, Trash2 } from "lucide-react"; import { File, FilePlus2, Loader2, Trash2 } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -9,7 +10,6 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { import {
Table, Table,
@@ -35,7 +35,7 @@ export const ShowPatches = ({ id, type }: Props) => {
const utils = api.useUtils(); const utils = api.useUtils();
const { data: patches, isLoading: isPatchesLoading } = const { data: patches, isPending: isPatchesLoading } =
api.patch.byEntityId.useQuery({ id, type }, { enabled: !!id }); api.patch.byEntityId.useQuery({ id, type }, { enabled: !!id });
const mutationMap = { const mutationMap = {

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Dices } from "lucide-react"; import { Dices } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -75,11 +75,11 @@ export const AddPreviewDomain = ({
}, },
); );
const { mutateAsync, isError, error, isLoading } = domainId const { mutateAsync, isError, error, isPending } = domainId
? api.domain.update.useMutation() ? api.domain.update.useMutation()
: api.domain.create.useMutation(); : api.domain.create.useMutation();
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } = const { mutateAsync: generateDomain, isPending: isLoadingGenerate } =
api.domain.generateDomain.useMutation(); api.domain.generateDomain.useMutation();
const form = useForm<Domain>({ const form = useForm<Domain>({
@@ -103,7 +103,7 @@ export const AddPreviewDomain = ({
if (!domainId) { if (!domainId) {
form.reset({}); form.reset({});
} }
}, [form, form.reset, data, isLoading]); }, [form, form.reset, data, isPending]);
const dictionary = { const dictionary = {
success: domainId ? "Domain Updated" : "Domain Created", success: domainId ? "Domain Updated" : "Domain Created",
@@ -301,7 +301,7 @@ export const AddPreviewDomain = ({
</form> </form>
<DialogFooter> <DialogFooter>
<Button isLoading={isLoading} form="hook-form" type="submit"> <Button isLoading={isPending} form="hook-form" type="submit">
{dictionary.submit} {dictionary.submit}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -43,7 +43,7 @@ interface Props {
export const ShowPreviewDeployments = ({ applicationId }: Props) => { export const ShowPreviewDeployments = ({ applicationId }: Props) => {
const { data } = api.application.one.useQuery({ applicationId }); const { data } = api.application.one.useQuery({ applicationId });
const { mutateAsync: deletePreviewDeployment, isLoading } = const { mutateAsync: deletePreviewDeployment, isPending } =
api.previewDeployment.delete.useMutation(); api.previewDeployment.delete.useMutation();
const { mutateAsync: redeployPreviewDeployment } = const { mutateAsync: redeployPreviewDeployment } =
@@ -57,8 +57,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
{ applicationId }, { applicationId },
{ {
enabled: !!applicationId, enabled: !!applicationId,
refetchInterval: (data) => refetchInterval: 2000,
data?.some((d) => d.previewStatus === "running") ? 2000 : false,
}, },
); );
@@ -282,7 +281,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
isLoading={isLoading} isLoading={isPending}
className="text-red-600 hover:text-red-700 hover:bg-red-50" className="text-red-600 hover:text-red-700 hover:bg-red-50"
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { HelpCircle, Plus, Settings2, X } from "lucide-react"; import { HelpCircle, Plus, Settings2, X } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -80,7 +80,7 @@ interface Props {
export const ShowPreviewSettings = ({ applicationId }: Props) => { export const ShowPreviewSettings = ({ applicationId }: Props) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isEnabled, setIsEnabled] = useState(false); const [isEnabled, setIsEnabled] = useState(false);
const { mutateAsync: updateApplication, isLoading } = const { mutateAsync: updateApplication, isPending } =
api.application.update.useMutation(); api.application.update.useMutation();
const { data, refetch } = api.application.one.useQuery({ applicationId }); const { data, refetch } = api.application.one.useQuery({ applicationId });
@@ -535,7 +535,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
Cancel Cancel
</Button> </Button>
<Button <Button
isLoading={isLoading} isLoading={isPending}
form="hook-form-delete-application" form="hook-form-delete-application"
type="submit" type="submit"
> >

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -71,7 +71,7 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
}, },
); );
const { mutateAsync: updateApplication, isLoading } = const { mutateAsync: updateApplication, isPending } =
api.application.update.useMutation(); api.application.update.useMutation();
const { data: registries } = api.registry.all.useQuery(); const { data: registries } = api.registry.all.useQuery();
@@ -212,7 +212,7 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
/> />
)} )}
<Button type="submit" className="w-full" isLoading={isLoading}> <Button type="submit" className="w-full" isLoading={isPending}>
Save Settings Save Settings
</Button> </Button>
</form> </form>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { import {
CheckIcon, CheckIcon,
ChevronsUpDown, ChevronsUpDown,
@@ -220,8 +220,8 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [cacheType, setCacheType] = useState<CacheType>("cache"); const [cacheType, setCacheType] = useState<CacheType>("cache");
const utils = api.useUtils(); const utils = api.useUtils();
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm({
resolver: zodResolver(formSchema), resolver: standardSchemaResolver(formSchema),
defaultValues: { defaultValues: {
name: "", name: "",
cronExpression: "", cronExpression: "",
@@ -275,11 +275,11 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
} }
}, [form, schedule, scheduleId]); }, [form, schedule, scheduleId]);
const { mutateAsync, isLoading } = scheduleId const { mutateAsync, isPending } = scheduleId
? api.schedule.update.useMutation() ? api.schedule.update.useMutation()
: api.schedule.create.useMutation(); : api.schedule.create.useMutation();
const onSubmit = async (values: z.infer<typeof formSchema>) => { const onSubmit = async (values: z.output<typeof formSchema>) => {
if (!id && !scheduleId) return; if (!id && !scheduleId) return;
await mutateAsync({ await mutateAsync({
@@ -662,7 +662,7 @@ echo "Hello, world!"
)} )}
/> />
<Button type="submit" isLoading={isLoading} className="w-full"> <Button type="submit" isLoading={isPending} className="w-full">
{scheduleId ? "Update" : "Create"} Schedule {scheduleId ? "Update" : "Create"} Schedule
</Button> </Button>
</form> </form>

View File

@@ -51,7 +51,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
}, },
); );
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync: deleteSchedule, isLoading: isDeleting } = const { mutateAsync: deleteSchedule, isPending: isDeleting } =
api.schedule.delete.useMutation(); api.schedule.delete.useMutation();
const { mutateAsync: runManually } = api.schedule.runManually.useMutation(); const { mutateAsync: runManually } = api.schedule.runManually.useMutation();

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon } from "lucide-react"; import { PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -43,7 +43,7 @@ interface Props {
export const UpdateApplication = ({ applicationId }: Props) => { export const UpdateApplication = ({ applicationId }: Props) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync, error, isError, isLoading } = const { mutateAsync, error, isError, isPending } =
api.application.update.useMutation(); api.application.update.useMutation();
const { data } = api.application.one.useQuery( const { data } = api.application.one.useQuery(
{ {
@@ -148,7 +148,7 @@ export const UpdateApplication = ({ applicationId }: Props) => {
/> />
<DialogFooter> <DialogFooter>
<Button <Button
isLoading={isLoading} isLoading={isPending}
form="hook-form-update-application" form="hook-form-update-application"
type="submit" type="submit"
> >

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { DatabaseZap, PenBoxIcon, PlusCircle, RefreshCw } from "lucide-react"; import { DatabaseZap, PenBoxIcon, PlusCircle, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -71,6 +71,7 @@ const formSchema = z
"mongo", "mongo",
"mysql", "mysql",
"redis", "redis",
"libsql",
]), ]),
serviceName: z.string(), serviceName: z.string(),
destinationId: z.string().min(1, "Destination required"), destinationId: z.string().min(1, "Destination required"),
@@ -116,7 +117,7 @@ export const HandleVolumeBackups = ({
const [keepLatestCountInput, setKeepLatestCountInput] = useState(""); const [keepLatestCountInput, setKeepLatestCountInput] = useState("");
const utils = api.useUtils(); const utils = api.useUtils();
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
name: "", name: "",
@@ -195,7 +196,7 @@ export const HandleVolumeBackups = ({
} }
}, [form, volumeBackup, volumeBackupId]); }, [form, volumeBackup, volumeBackupId]);
const { mutateAsync, isLoading } = volumeBackupId const { mutateAsync, isPending } = volumeBackupId
? api.volumeBackups.update.useMutation() ? api.volumeBackups.update.useMutation()
: api.volumeBackups.create.useMutation(); : api.volumeBackups.create.useMutation();
@@ -207,7 +208,7 @@ export const HandleVolumeBackups = ({
await mutateAsync({ await mutateAsync({
...values, ...values,
keepLatestCount: preparedKeepLatestCount, keepLatestCount: preparedKeepLatestCount ?? undefined,
destinationId: values.destinationId, destinationId: values.destinationId,
volumeBackupId: volumeBackupId || "", volumeBackupId: volumeBackupId || "",
serviceType: volumeBackupType, serviceType: volumeBackupType,
@@ -482,7 +483,7 @@ export const HandleVolumeBackups = ({
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription> <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 volume here, you can type the volume name manually
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
@@ -517,7 +518,7 @@ export const HandleVolumeBackups = ({
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription> <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 here, you can type the volume name manually
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
@@ -630,7 +631,7 @@ export const HandleVolumeBackups = ({
)} )}
/> />
<Button type="submit" isLoading={isLoading} className="w-full"> <Button type="submit" isLoading={isPending} className="w-full">
{volumeBackupId ? "Update" : "Create"} Volume Backup {volumeBackupId ? "Update" : "Create"} Volume Backup
</Button> </Button>
</form> </form>

View File

@@ -1,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { debounce } from "lodash"; import debounce from "lodash/debounce";
import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react"; import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -53,27 +53,15 @@ interface Props {
} }
const RestoreBackupSchema = z.object({ const RestoreBackupSchema = z.object({
destinationId: z destinationId: z.string().min(1, {
.string({ message: "Destination is required",
required_error: "Please select a destination", }),
}) backupFile: z.string().min(1, {
.min(1, { message: "Backup file is required",
message: "Destination is required", }),
}), volumeName: z.string().min(1, {
backupFile: z message: "Volume name is required",
.string({ }),
required_error: "Please select a backup file",
})
.min(1, {
message: "Backup file is required",
}),
volumeName: z
.string({
required_error: "Please enter a volume name",
})
.min(1, {
message: "Volume name is required",
}),
}); });
export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => { export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
@@ -83,7 +71,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
const { data: destinations = [] } = api.destination.all.useQuery(); const { data: destinations = [] } = api.destination.all.useQuery();
const form = useForm<z.infer<typeof RestoreBackupSchema>>({ const form = useForm({
defaultValues: { defaultValues: {
destinationId: "", destinationId: "",
backupFile: "", backupFile: "",
@@ -105,7 +93,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
debouncedSetSearch(value); debouncedSetSearch(value);
}; };
const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery( const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery(
{ {
destinationId: destinationId, destinationId: destinationId,
search: debouncedSearchTerm, search: debouncedSearchTerm,
@@ -294,7 +282,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
onValueChange={handleSearchChange} onValueChange={handleSearchChange}
className="h-9" className="h-9"
/> />
{isLoading ? ( {isPending ? (
<div className="py-6 text-center text-sm"> <div className="py-6 text-center text-sm">
Loading backup files... Loading backup files...
</div> </div>

View File

@@ -54,7 +54,7 @@ export const ShowVolumeBackups = ({
}, },
); );
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync: deleteVolumeBackup, isLoading: isDeleting } = const { mutateAsync: deleteVolumeBackup, isPending: isDeleting } =
api.volumeBackups.delete.useMutation(); api.volumeBackups.delete.useMutation();
const { mutateAsync: runManually } = const { mutateAsync: runManually } =
api.volumeBackups.runManually.useMutation(); api.volumeBackups.runManually.useMutation();

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -52,7 +52,7 @@ export const AddCommandCompose = ({ composeId }: Props) => {
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync, isLoading } = api.compose.update.useMutation(); const { mutateAsync, isPending } = api.compose.update.useMutation();
const form = useForm<AddCommand>({ const form = useForm<AddCommand>({
defaultValues: { defaultValues: {
@@ -128,7 +128,7 @@ export const AddCommandCompose = ({ composeId }: Props) => {
/> />
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button isLoading={isLoading} type="submit" className="w-fit"> <Button isLoading={isPending} type="submit" className="w-fit">
Save Save
</Button> </Button>
</div> </div>

View File

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

View File

@@ -1,5 +1,5 @@
import type { ServiceType } from "@dokploy/server/db/schema"; import type { ServiceType } from "@dokploy/server/db/schema";
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { Copy, Trash2 } from "lucide-react"; import { Copy, Trash2 } from "lucide-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@@ -46,6 +46,8 @@ interface Props {
} }
export const DeleteService = ({ id, type }: Props) => { export const DeleteService = ({ id, type }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canDelete = permissions?.service.delete ?? false;
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const queryMap = { const queryMap = {
@@ -55,6 +57,7 @@ export const DeleteService = ({ id, type }: Props) => {
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () => mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
application: () => application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
@@ -70,11 +73,12 @@ export const DeleteService = ({ id, type }: Props) => {
redis: () => api.redis.remove.useMutation(), redis: () => api.redis.remove.useMutation(),
mysql: () => api.mysql.remove.useMutation(), mysql: () => api.mysql.remove.useMutation(),
mariadb: () => api.mariadb.remove.useMutation(), mariadb: () => api.mariadb.remove.useMutation(),
libsql: () => api.libsql.remove.useMutation(),
application: () => api.application.delete.useMutation(), application: () => api.application.delete.useMutation(),
mongo: () => api.mongo.remove.useMutation(), mongo: () => api.mongo.remove.useMutation(),
compose: () => api.compose.delete.useMutation(), compose: () => api.compose.delete.useMutation(),
}; };
const { mutateAsync, isLoading } = mutationMap[type] const { mutateAsync, isPending } = mutationMap[type]
? mutationMap[type]() ? mutationMap[type]()
: api.mongo.remove.useMutation(); : api.mongo.remove.useMutation();
const { push } = useRouter(); const { push } = useRouter();
@@ -96,6 +100,7 @@ export const DeleteService = ({ id, type }: Props) => {
redisId: id || "", redisId: id || "",
mysqlId: id || "", mysqlId: id || "",
mariadbId: id || "", mariadbId: id || "",
libsqlId: id || "",
applicationId: id || "", applicationId: id || "",
composeId: id || "", composeId: id || "",
deleteVolumes, deleteVolumes,
@@ -123,6 +128,8 @@ export const DeleteService = ({ id, type }: Props) => {
data?.applicationStatus === "running") || data?.applicationStatus === "running") ||
(data && "composeStatus" in data && data?.composeStatus === "running"); (data && "composeStatus" in data && data?.composeStatus === "running");
if (!canDelete) return null;
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
@@ -130,7 +137,7 @@ export const DeleteService = ({ id, type }: Props) => {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="group hover:bg-red-500/10 " className="group hover:bg-red-500/10 "
isLoading={isLoading} isLoading={isPending}
> >
<Trash2 className="size-4 text-primary group-hover:text-red-500" /> <Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button> </Button>
@@ -228,7 +235,7 @@ export const DeleteService = ({ id, type }: Props) => {
</Button> </Button>
<Button <Button
isLoading={isLoading} isLoading={isPending}
disabled={isDisabled} disabled={isDisabled}
form="hook-form-delete-compose" form="hook-form-delete-compose"
type="submit" type="submit"

View File

@@ -19,6 +19,9 @@ interface Props {
} }
export const ComposeActions = ({ composeId }: Props) => { export const ComposeActions = ({ composeId }: Props) => {
const router = useRouter(); const router = useRouter();
const { data: permissions } = api.user.getPermissions.useQuery();
const canDeploy = permissions?.deployment.create ?? false;
const canUpdateService = permissions?.service.create ?? false;
const { data, refetch } = api.compose.one.useQuery( const { data, refetch } = api.compose.one.useQuery(
{ {
composeId, composeId,
@@ -28,169 +31,176 @@ export const ComposeActions = ({ composeId }: Props) => {
const { mutateAsync: update } = api.compose.update.useMutation(); const { mutateAsync: update } = api.compose.update.useMutation();
const { mutateAsync: deploy } = api.compose.deploy.useMutation(); const { mutateAsync: deploy } = api.compose.deploy.useMutation();
const { mutateAsync: redeploy } = api.compose.redeploy.useMutation(); const { mutateAsync: redeploy } = api.compose.redeploy.useMutation();
const { mutateAsync: start, isLoading: isStarting } = const { mutateAsync: start, isPending: isStarting } =
api.compose.start.useMutation(); api.compose.start.useMutation();
const { mutateAsync: stop, isLoading: isStopping } = const { mutateAsync: stop, isPending: isStopping } =
api.compose.stop.useMutation(); api.compose.stop.useMutation();
return ( return (
<div className="flex flex-row gap-4 w-full flex-wrap "> <div className="flex flex-row gap-4 w-full flex-wrap ">
<TooltipProvider delayDuration={0} disableHoverableContent={false}> <TooltipProvider delayDuration={0} disableHoverableContent={false}>
<DialogAction {canDeploy && (
title="Deploy Compose"
description="Are you sure you want to deploy this compose?"
type="default"
onClick={async () => {
await deploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying compose");
});
}}
>
<Button
variant="default"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads the source code and performs a complete build</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<DialogAction
title="Reload Compose"
description="Are you sure you want to reload this compose?"
type="default"
onClick={async () => {
await redeploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading compose");
});
}}
>
<Button
variant="secondary"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Reload the compose without rebuilding it</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
{data?.composeType === "docker-compose" &&
data?.composeStatus === "idle" ? (
<DialogAction <DialogAction
title="Start Compose" title="Deploy Compose"
description="Are you sure you want to start this compose?" description="Are you sure you want to deploy this compose?"
type="default" type="default"
onClick={async () => { onClick={async () => {
await start({ await deploy({
composeId: composeId, composeId: composeId,
}) })
.then(() => { .then(() => {
toast.success("Compose started successfully"); toast.success("Compose deployed successfully");
refetch(); refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
);
}) })
.catch(() => { .catch(() => {
toast.error("Error starting compose"); toast.error("Error deploying compose");
}); });
}} }}
> >
<Button <Button
variant="secondary" variant="default"
isLoading={isStarting} isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2" className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center"> <div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" /> <Rocket className="size-4 mr-1" />
Start Deploy
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipContent sideOffset={5} className="z-[60]">
<p> <p>
Start the compose (requires a previous successful build) Downloads the source code and performs a complete build
</p> </p>
</TooltipContent> </TooltipContent>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
</Tooltip> </Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
) : ( )}
{canDeploy && (
<DialogAction <DialogAction
title="Stop Compose" title="Reload Compose"
description="Are you sure you want to stop this compose?" description="Are you sure you want to reload this compose?"
type="default"
onClick={async () => { onClick={async () => {
await stop({ await redeploy({
composeId: composeId, composeId: composeId,
}) })
.then(() => { .then(() => {
toast.success("Compose stopped successfully"); toast.success("Compose reloaded successfully");
refetch(); refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error stopping compose"); toast.error("Error reloading compose");
}); });
}} }}
> >
<Button <Button
variant="destructive" variant="secondary"
isLoading={isStopping} isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2" className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center"> <div className="flex items-center">
<Ban className="size-4 mr-1" /> <RefreshCcw className="size-4 mr-1" />
Stop Reload
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running compose</p> <p>Reload the compose without rebuilding it</p>
</TooltipContent> </TooltipContent>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
</Tooltip> </Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
)} )}
{canDeploy &&
(data?.composeType === "docker-compose" &&
data?.composeStatus === "idle" ? (
<DialogAction
title="Start Compose"
description="Are you sure you want to start this compose?"
type="default"
onClick={async () => {
await start({
composeId: composeId,
})
.then(() => {
toast.success("Compose started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting compose");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the compose (requires a previous successful build)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Compose"
description="Are you sure you want to stop this compose?"
onClick={async () => {
await stop({
composeId: composeId,
})
.then(() => {
toast.success("Compose stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping compose");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running compose</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
))}
</TooltipProvider> </TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}
@@ -205,27 +215,29 @@ export const ComposeActions = ({ composeId }: Props) => {
Open Terminal Open Terminal
</Button> </Button>
</DockerTerminalModal> </DockerTerminalModal>
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border"> {canUpdateService && (
<span className="text-sm font-medium">Autodeploy</span> <div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<Switch <span className="text-sm font-medium">Autodeploy</span>
aria-label="Toggle autodeploy" <Switch
checked={data?.autoDeploy || false} aria-label="Toggle autodeploy"
onCheckedChange={async (enabled) => { checked={data?.autoDeploy || false}
await update({ onCheckedChange={async (enabled) => {
composeId, await update({
autoDeploy: enabled, composeId,
}) autoDeploy: enabled,
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
}) })
.catch(() => { .then(async () => {
toast.error("Error updating Auto Deploy"); toast.success("Auto Deploy Updated");
}); await refetch();
}} })
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" .catch(() => {
/> toast.error("Error updating Auto Deploy");
</div> });
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -26,6 +26,8 @@ const AddComposeFile = z.object({
type AddComposeFile = z.infer<typeof AddComposeFile>; type AddComposeFile = z.infer<typeof AddComposeFile>;
export const ComposeFileEditor = ({ composeId }: Props) => { export const ComposeFileEditor = ({ composeId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canUpdate = permissions?.service.create ?? false;
const utils = api.useUtils(); const utils = api.useUtils();
const { data, refetch } = api.compose.one.useQuery( const { data, refetch } = api.compose.one.useQuery(
{ {
@@ -34,7 +36,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
{ enabled: !!composeId }, { enabled: !!composeId },
); );
const { mutateAsync, isLoading } = api.compose.update.useMutation(); const { mutateAsync, isPending } = api.compose.update.useMutation();
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const form = useForm<AddComposeFile>({ const form = useForm<AddComposeFile>({
@@ -93,7 +95,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S // Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) { if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
e.preventDefault(); e.preventDefault();
form.handleSubmit(onSubmit)(); form.handleSubmit(onSubmit)();
} }
@@ -103,7 +105,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
return () => { return () => {
document.removeEventListener("keydown", handleKeyDown); document.removeEventListener("keydown", handleKeyDown);
}; };
}, [form, onSubmit, isLoading]); }, [form, onSubmit, isPending]);
return ( return (
<> <>
@@ -164,14 +166,16 @@ services:
</Form> </Form>
<div className="flex justify-between flex-col lg:flex-row gap-2"> <div className="flex justify-between flex-col lg:flex-row gap-2">
<div className="w-full flex flex-col lg:flex-row gap-4 items-end" /> <div className="w-full flex flex-col lg:flex-row gap-4 items-end" />
<Button {canUpdate && (
type="submit" <Button
form="hook-form-save-compose-file" type="submit"
isLoading={isLoading} form="hook-form-save-compose-file"
className="lg:w-fit w-full" isLoading={isPending}
> className="lg:w-fit w-full"
Save >
</Button> Save
</Button>
)}
</div> </div>
</div> </div>
</> </>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -74,10 +74,10 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
api.bitbucket.bitbucketProviders.useQuery(); api.bitbucket.bitbucketProviders.useQuery();
const { data, refetch } = api.compose.one.useQuery({ composeId }); const { data, refetch } = api.compose.one.useQuery({ composeId });
const { mutateAsync, isLoading: isSavingBitbucketProvider } = const { mutateAsync, isPending: isSavingBitbucketProvider } =
api.compose.update.useMutation(); api.compose.update.useMutation();
const form = useForm<BitbucketProvider>({ const form = useForm({
defaultValues: { defaultValues: {
composePath: "./docker-compose.yml", composePath: "./docker-compose.yml",
repository: { repository: {
@@ -335,7 +335,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
{status === "loading" && fetchStatus === "fetching" {status === "pending" && fetchStatus === "fetching"
? "Loading...." ? "Loading...."
: field.value : field.value
? branches?.find( ? branches?.find(
@@ -352,7 +352,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
placeholder="Search branch..." placeholder="Search branch..."
className="h-9" className="h-9"
/> />
{status === "loading" && fetchStatus === "fetching" && ( {status === "pending" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground"> <span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches.... Loading Branches....
</span> </span>

View File

@@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { KeyRoundIcon, LockIcon, X } from "lucide-react"; import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -55,12 +55,12 @@ interface Props {
export const SaveGitProviderCompose = ({ composeId }: Props) => { export const SaveGitProviderCompose = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery({ composeId }); 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 router = useRouter();
const { mutateAsync, isLoading } = api.compose.update.useMutation(); const { mutateAsync, isPending } = api.compose.update.useMutation();
const form = useForm<GitProvider>({ const form = useForm({
defaultValues: { defaultValues: {
branch: "", branch: "",
repositoryURL: "", repositoryURL: "",
@@ -230,10 +230,8 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel> <FormLabel>Watch Paths</FormLabel>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger asChild>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold"> <HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
?
</div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="max-w-[300px]"> <TooltipContent className="max-w-[300px]">
<p> <p>
@@ -318,7 +316,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
</div> </div>
<div className="flex flex-row justify-end"> <div className="flex flex-row justify-end">
<Button type="submit" className="w-fit" isLoading={isLoading}> <Button type="submit" className="w-fit" isLoading={isPending}>
Save{" "} Save{" "}
</Button> </Button>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod"; 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 Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -72,10 +72,10 @@ interface Props {
export const SaveGiteaProviderCompose = ({ composeId }: Props) => { export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery(); const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data, refetch } = api.compose.one.useQuery({ composeId }); const { data, refetch } = api.compose.one.useQuery({ composeId });
const { mutateAsync, isLoading: isSavingGiteaProvider } = const { mutateAsync, isPending: isSavingGiteaProvider } =
api.compose.update.useMutation(); api.compose.update.useMutation();
const form = useForm<GiteaProvider>({ const form = useForm({
defaultValues: { defaultValues: {
composePath: "./docker-compose.yml", composePath: "./docker-compose.yml",
repository: { repository: {
@@ -331,7 +331,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
{status === "loading" && fetchStatus === "fetching" {status === "pending" && fetchStatus === "fetching"
? "Loading...." ? "Loading...."
: field.value : field.value
? branches?.find( ? branches?.find(
@@ -409,10 +409,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel> <FormLabel>Watch Paths</FormLabel>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger asChild>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold"> <HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
?
</div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p> <p>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -72,10 +72,10 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
const { data: githubProviders } = api.github.githubProviders.useQuery(); const { data: githubProviders } = api.github.githubProviders.useQuery();
const { data, refetch } = api.compose.one.useQuery({ composeId }); const { data, refetch } = api.compose.one.useQuery({ composeId });
const { mutateAsync, isLoading: isSavingGithubProvider } = const { mutateAsync, isPending: isSavingGithubProvider } =
api.compose.update.useMutation(); api.compose.update.useMutation();
const form = useForm<GithubProvider>({ const form = useForm({
defaultValues: { defaultValues: {
composePath: "./docker-compose.yml", composePath: "./docker-compose.yml",
repository: { repository: {
@@ -94,7 +94,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
const repository = form.watch("repository"); const repository = form.watch("repository");
const githubId = form.watch("githubId"); const githubId = form.watch("githubId");
const triggerType = form.watch("triggerType"); const triggerType = form.watch("triggerType");
const { data: repositories, isLoading: isLoadingRepositories } = const { data: repositories, isPending: isLoadingRepositories } =
api.github.getGithubRepositories.useQuery( api.github.getGithubRepositories.useQuery(
{ {
githubId, githubId,
@@ -321,7 +321,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
{status === "loading" && fetchStatus === "fetching" {status === "pending" && fetchStatus === "fetching"
? "Loading...." ? "Loading...."
: field.value : field.value
? branches?.find( ? branches?.find(
@@ -338,7 +338,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
placeholder="Search branch..." placeholder="Search branch..."
className="h-9" className="h-9"
/> />
{status === "loading" && fetchStatus === "fetching" && ( {status === "pending" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground"> <span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches.... Loading Branches....
</span> </span>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
@@ -74,10 +74,10 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery(); const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
const { data, refetch } = api.compose.one.useQuery({ composeId }); const { data, refetch } = api.compose.one.useQuery({ composeId });
const { mutateAsync, isLoading: isSavingGitlabProvider } = const { mutateAsync, isPending: isSavingGitlabProvider } =
api.compose.update.useMutation(); api.compose.update.useMutation();
const form = useForm<GitlabProvider>({ const form = useForm({
defaultValues: { defaultValues: {
composePath: "./docker-compose.yml", composePath: "./docker-compose.yml",
repository: { repository: {
@@ -353,7 +353,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
{status === "loading" && fetchStatus === "fetching" {status === "pending" && fetchStatus === "fetching"
? "Loading...." ? "Loading...."
: field.value : field.value
? branches?.find( ? branches?.find(
@@ -370,7 +370,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
placeholder="Search branch..." placeholder="Search branch..."
className="h-9" className="h-9"
/> />
{status === "loading" && fetchStatus === "fetching" && ( {status === "pending" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground"> <span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches.... Loading Branches....
</span> </span>

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