Compare commits

..

4 Commits

Author SHA1 Message Date
Mauricio Siu 6a7da40ffe Merge branch 'canary' into feat/add-custom-dokploy-loader 2026-03-01 15:53:33 -06:00
Mauricio Siu 7a1703a191 fix(show-registry): correct loading state condition for WhaleLoader display
- Updated the ShowRegistry component to display the WhaleLoader when loading is in progress, improving user experience during data fetching.
2026-02-08 02:27:01 -06:00
Mauricio Siu f6af5daf5e refactor(animation): update whale-loader animations and styles
- Removed the deprecated "whale-draw" animation from the Tailwind CSS configuration.
- Added a new "whale-draw" keyframe animation in globals.css to enhance the whale-loader's visual effect.
- Updated the WhaleLoader component to utilize the new animation and adjusted stroke properties for improved rendering.
2026-02-08 02:26:49 -06:00
Mauricio Siu 452b9a3c78 feat(loader): add WhaleLoader component and animation
- Introduced a new WhaleLoader component that displays a loading animation using the Dokploy whale logo.
- Updated the Tailwind CSS configuration to include a new animation for the whale loader.
- Replaced the existing loading indicator in the ShowRegistry component with the new WhaleLoader for improved visual consistency.
2026-02-08 02:19:05 -06:00
542 changed files with 30836 additions and 283446 deletions
-79
View File
@@ -138,8 +138,6 @@ jobs:
needs: [combine-manifests]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -162,80 +160,3 @@ jobs:
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
sync-version:
needs: [generate-release]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Sync version to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
cd /tmp/mcp-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
npm install -g pnpm
pnpm install
pnpm run fetch-openapi
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.version }}"
- name: Sync version to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
cd /tmp/cli-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.version }}"
- name: Sync version to SDK repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
cd /tmp/sdk-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.version }}"
+22
View File
@@ -0,0 +1,22 @@
name: PR Quality
permissions:
contents: read
issues: read
pull-requests: write
on:
pull_request_target:
types: [opened, reopened]
jobs:
anti-slop:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@v0
with:
max-failures: 4
blocked-commit-authors: "claude,copilot"
require-description: true
min-account-age: 5
-63
View File
@@ -68,66 +68,3 @@ jobs:
echo "✅ OpenAPI synced to website successfully"
- name: Sync to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git mcp-repo
cd mcp-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to MCP repository successfully"
- name: Sync to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git cli-repo
cd cli-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to CLI repository successfully"
- name: Sync to SDK repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git sdk-repo
cd sdk-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to SDK repository successfully"
-3
View File
@@ -4,8 +4,5 @@
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
+1 -8
View File
@@ -99,14 +99,7 @@ pnpm run dokploy:build
## Docker
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
To build the docker image
```bash
pnpm run docker:build
+1 -1
View File
@@ -66,7 +66,7 @@ COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
HEALTHCHECK --interval=10s --timeout=3s --retries=10 \
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
+2 -2
View File
@@ -19,7 +19,7 @@ Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies th
Dokploy includes multiple features to make your life easier.
- **Applications**: Deploy any type of application (Node.js, PHP, Python, Go, Ruby, etc.).
- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, libsql, and Redis.
- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, and Redis.
- **Backups**: Automate backups for databases to an external storage destination.
- **Docker Compose**: Native support for Docker Compose to manage complex applications.
- **Multi Node**: Scale applications to multiple nodes using Docker Swarm to manage the cluster.
@@ -39,7 +39,7 @@ To get started, run the following command on a VPS:
Want to skip the installation process? [Try the Dokploy Cloud](https://app.dokploy.com).
```bash
curl -sSL https://dokploy.com/install.sh | bash
curl -sSL https://dokploy.com/install.sh | sh
```
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
+1 -10
View File
@@ -1,11 +1,2 @@
LEMON_SQUEEZY_API_KEY=""
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
LEMON_SQUEEZY_STORE_ID=""
+1 -24
View File
@@ -10,7 +10,6 @@ import {
type DeployJob,
deployJobSchema,
} from "./schema.js";
import { fetchDeploymentJobs } from "./service.js";
import { deploy } from "./utils.js";
const app = new Hono();
@@ -119,6 +118,7 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => {
200,
);
} catch (error) {
console.log("error", error);
logger.error("Failed to send deployment event", error);
return c.json(
{
@@ -176,29 +176,6 @@ app.get("/health", async (c) => {
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
app.on(
["GET", "POST", "PUT"],
-239
View File
@@ -1,239 +0,0 @@
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);
};
+1 -1
View File
@@ -9,7 +9,7 @@ import {
updateCompose,
updatePreviewDeployment,
} from "@dokploy/server";
import type { DeployJob } from "./schema.js";
import type { DeployJob } from "./schema";
export const deploy = async (job: DeployJob) => {
try {
@@ -1,52 +0,0 @@
import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose";
import { describe, expect, it, vi } from "vitest";
// Isolate the command builder from the compose-file I/O performed by
// writeDomainsToCompose; we only care about the docker invocation it emits.
vi.mock("@dokploy/server/utils/docker/domain", () => ({
writeDomainsToCompose: vi.fn().mockResolvedValue(""),
}));
const baseCompose = {
appName: "my-app",
sourceType: "raw",
command: "",
composePath: "docker-compose.yml",
composeType: "stack",
isolatedDeployment: false,
randomize: false,
suffix: "",
serverId: null,
env: "",
mounts: [],
domains: [],
environment: { project: { env: "" }, env: "" },
} as unknown as Parameters<typeof getBuildComposeCommand>[0];
// Regression coverage for #4401: the deploy command runs under `env -i`, which
// clears the environment except for the vars listed explicitly. HOME must be
// preserved so docker can resolve ~/.docker/config.json — otherwise
// `docker stack deploy --with-registry-auth` ships no credentials to the swarm
// and private-registry images fail to pull.
describe("getBuildComposeCommand registry auth (#4401)", () => {
it("preserves HOME for swarm stack deploys", async () => {
const command = await getBuildComposeCommand({
...baseCompose,
composeType: "stack",
});
expect(command).toContain("stack deploy");
expect(command).toContain("--with-registry-auth");
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
});
it("preserves HOME for docker compose deploys", async () => {
const command = await getBuildComposeCommand({
...baseCompose,
composeType: "docker-compose",
});
expect(command).toContain("compose -p my-app");
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
});
});
@@ -32,9 +32,6 @@ describe("Host rule format regression tests", () => {
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
customEntrypoint: null,
middlewares: null,
forwardAuthEnabled: false,
};
describe("Host rule format validation", () => {
@@ -7,7 +7,6 @@ describe("createDomainLabels", () => {
const baseDomain: Domain = {
host: "example.com",
port: 8080,
customEntrypoint: null,
https: false,
uniqueConfigKey: 1,
customCertResolver: null,
@@ -22,8 +21,6 @@ describe("createDomainLabels", () => {
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
};
it("should create basic labels for web entrypoint", async () => {
@@ -104,51 +101,6 @@ describe("createDomainLabels", () => {
);
});
it("should add tls=true for certificateType none on websecure entrypoint", async () => {
const noneDomain = {
...baseDomain,
https: true,
certificateType: "none" as const,
};
const labels = await createDomainLabels(appName, noneDomain, "websecure");
expect(labels).toContain(
"traefik.http.routers.test-app-1-websecure.tls=true",
);
// no cert resolver should be set when relying on a default/custom cert
expect(labels).not.toContain(
"traefik.http.routers.test-app-1-websecure.tls.certresolver=letsencrypt",
);
});
it("should not add tls=true for certificateType none on web entrypoint", async () => {
const noneDomain = {
...baseDomain,
https: true,
certificateType: "none" as const,
};
const labels = await createDomainLabels(appName, noneDomain, "web");
expect(labels).not.toContain(
"traefik.http.routers.test-app-1-web.tls=true",
);
});
it("should add tls=true for certificateType none on a custom https entrypoint", async () => {
const noneDomain = {
...baseDomain,
https: true,
customEntrypoint: "websecure-custom",
certificateType: "none" as const,
};
const labels = await createDomainLabels(
appName,
noneDomain,
"websecure-custom",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-websecure-custom.tls=true",
);
});
it("should handle different ports correctly", async () => {
const customPortDomain = { ...baseDomain, port: 3000 };
const labels = await createDomainLabels(appName, customPortDomain, "web");
@@ -219,12 +171,12 @@ describe("createDomainLabels", () => {
"websecure",
);
// Web entrypoint with HTTPS should only have redirect
// Web entrypoint should have both middlewares with redirect first
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,addprefix-test-app-1",
);
// Websecure should have the addprefix middleware
// Websecure should only have the addprefix middleware
expect(websecureLabels).toContain(
"traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1",
);
@@ -256,9 +208,9 @@ describe("createDomainLabels", () => {
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
// Web router with HTTPS should only have redirect
// Should have middlewares in correct order: redirect, stripprefix, addprefix
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,stripprefix-test-app-1,addprefix-test-app-1",
);
});
@@ -288,259 +240,4 @@ describe("createDomainLabels", () => {
"traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
);
});
it("should add single custom middleware to router", async () => {
const customMiddlewareDomain = {
...baseDomain,
middlewares: ["auth@file"],
};
const labels = await createDomainLabels(
appName,
customMiddlewareDomain,
"web",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=auth@file",
);
});
it("should add multiple custom middlewares to router", async () => {
const customMiddlewareDomain = {
...baseDomain,
middlewares: ["auth@file", "rate-limit@file"],
};
const labels = await createDomainLabels(
appName,
customMiddlewareDomain,
"web",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=auth@file,rate-limit@file",
);
});
it("should only have redirect on web router when HTTPS is enabled with custom middlewares", async () => {
const combinedDomain = {
...baseDomain,
https: true,
middlewares: ["auth@file"],
};
const labels = await createDomainLabels(appName, combinedDomain, "web");
// Web router with HTTPS should only redirect, custom middlewares go on websecure
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
expect(labels).not.toContain("auth@file");
});
it("should combine custom middlewares with stripPath middleware (no HTTPS)", async () => {
const combinedDomain = {
...baseDomain,
path: "/api",
stripPath: true,
middlewares: ["auth@file"],
};
const labels = await createDomainLabels(appName, combinedDomain, "web");
// stripprefix should come before custom middleware
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=stripprefix-test-app-1,auth@file",
);
});
it("should only have redirect on web router even with all built-in middlewares and custom middlewares", async () => {
const fullDomain = {
...baseDomain,
https: true,
path: "/api",
stripPath: true,
internalPath: "/hello",
middlewares: ["auth@file", "rate-limit@file"],
};
const webLabels = await createDomainLabels(appName, fullDomain, "web");
// Web router with HTTPS should only redirect
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
// Middleware definitions should still be present (Traefik needs them registered)
expect(webLabels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(webLabels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
// But they should NOT be attached to the router
expect(webLabels).not.toContain("stripprefix-test-app-1,");
expect(webLabels).not.toContain("auth@file");
expect(webLabels).not.toContain("rate-limit@file");
});
it("should include custom middlewares on websecure entrypoint", async () => {
const customMiddlewareDomain = {
...baseDomain,
https: true,
middlewares: ["auth@file"],
};
const websecureLabels = await createDomainLabels(
appName,
customMiddlewareDomain,
"websecure",
);
// Websecure should have custom middleware but not redirect-to-https
expect(websecureLabels).toContain(
"traefik.http.routers.test-app-1-websecure.middlewares=auth@file",
);
expect(websecureLabels).not.toContain("redirect-to-https");
});
it("should NOT include custom middlewares on web router when HTTPS is enabled (only redirect)", async () => {
const domain = {
...baseDomain,
https: true,
middlewares: ["rate-limit@file", "auth@file"],
};
const webLabels = await createDomainLabels(appName, domain, "web");
// Web router with HTTPS should ONLY have redirect, not custom middlewares
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
expect(webLabels).not.toContain("rate-limit@file");
expect(webLabels).not.toContain("auth@file");
});
it("should create basic labels for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{ ...baseDomain, customEntrypoint: "custom" },
"custom",
);
expect(labels).toEqual([
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
]);
});
it("should create https labels for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
https: true,
customEntrypoint: "custom",
certificateType: "letsencrypt",
},
"custom",
);
expect(labels).toEqual([
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
"traefik.http.routers.test-app-1-custom.tls.certresolver=letsencrypt",
]);
});
it("should add stripPath middleware for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1",
);
});
it("should add internalPath middleware for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
internalPath: "/hello",
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=addprefix-test-app-1",
);
});
it("should add path prefix in rule for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
},
"custom",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`) && PathPrefix(`/api`)",
);
});
it("should combine all middlewares for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
internalPath: "/hello",
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(labels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
);
});
it("should not add redirect-to-https for custom entrypoint even with https", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
https: true,
certificateType: "letsencrypt",
},
"custom",
);
const middlewareLabel = labels.find((l) => l.includes(".middlewares="));
// Should not contain redirect-to-https since there's only one router
expect(middlewareLabel).toBeUndefined();
});
});
@@ -292,7 +292,7 @@ networks:
dokploy-network:
`;
test("It shouldn't add suffix to dokploy-network", () => {
test("It shoudn't add suffix to dokploy-network", () => {
const composeData = parse(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -195,7 +195,7 @@ services:
- dokploy-network
`;
test("It shouldn't add suffix to dokploy-network in services", () => {
test("It shoudn't add suffix to dokploy-network in services", () => {
const composeData = parse(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -241,10 +241,10 @@ services:
dokploy-network:
aliases:
- apid
`;
test("It shouldn't add suffix to dokploy-network in services multiples cases", () => {
test("It shoudn't add suffix to dokploy-network in services multiples cases", () => {
const composeData = parse(composeFile8) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -415,24 +415,5 @@ describe("Docker Image Name and Tag Extraction", () => {
expect(extractImageTag("my-image:123")).toBe("123");
expect(extractImageTag("my-image:1")).toBe("1");
});
it("should return 'latest' for registry with port but no tag", () => {
expect(extractImageTag("registry.example.com:5000/myimage")).toBe(
"latest",
);
expect(extractImageTag("registry:5000/fedora/httpd")).toBe("latest");
expect(extractImageTag("localhost:5000/myapp")).toBe("latest");
expect(extractImageTag("my-registry.io:443/org/app")).toBe("latest");
});
it("should extract tag from registry with port and tag", () => {
expect(extractImageTag("registry:5000/image:tag")).toBe("tag");
expect(extractImageTag("registry.example.com:5000/myimage:v2.0")).toBe(
"v2.0",
);
expect(extractImageTag("localhost:5000/app:sha-abc123")).toBe(
"sha-abc123",
);
});
});
});
@@ -1,41 +0,0 @@
import { shouldDeploy } from "@dokploy/server";
import { describe, expect, it } from "vitest";
describe("shouldDeploy", () => {
it("should deploy when no watch paths are configured", () => {
expect(shouldDeploy(null, ["src/index.ts"])).toBe(true);
expect(shouldDeploy([], ["src/index.ts"])).toBe(true);
});
it("should deploy when watch paths match modified files", () => {
expect(shouldDeploy(["src/**"], ["src/index.ts"])).toBe(true);
expect(shouldDeploy(["apps/web/**"], ["apps/web/page.tsx"])).toBe(true);
});
it("should not deploy when watch paths do not match", () => {
expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false);
});
it("should not throw when modified files contain non-string values", () => {
expect(() =>
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
).not.toThrow();
expect(
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
).toBe(true);
});
it("should not throw when modified files are undefined or null", () => {
expect(() => shouldDeploy(["src/**"], undefined)).not.toThrow();
expect(() => shouldDeploy(["src/**"], null)).not.toThrow();
expect(shouldDeploy(["src/**"], undefined)).toBe(false);
expect(shouldDeploy(["src/**"], null)).toBe(false);
});
it("should not throw when every modified file is non-string", () => {
expect(() =>
shouldDeploy(["src/**"], [undefined, undefined] as any),
).not.toThrow();
expect(shouldDeploy(["src/**"], [undefined, undefined] as any)).toBe(false);
});
});
-1
View File
@@ -120,7 +120,6 @@ const baseApp: ApplicationNested = {
environmentId: "",
enabled: null,
env: null,
icon: null,
healthCheckSwarm: null,
labelsSwarm: null,
memoryLimit: null,
+10 -10
View File
@@ -1,4 +1,4 @@
import { getEnvironmentVariablesObject } from "@dokploy/server/index";
import { getEnviromentVariablesObject } from "@dokploy/server/index";
import { describe, expect, it } from "vitest";
const projectEnv = `
@@ -15,7 +15,7 @@ DATABASE_NAME=dev_database
SECRET_KEY=env-secret-123
`;
describe("getEnvironmentVariablesObject with environment variables (Stack compose)", () => {
describe("getEnviromentVariablesObject with environment variables (Stack compose)", () => {
it("resolves environment variables correctly for Stack compose", () => {
const serviceEnv = `
FOO=\${{environment.NODE_ENV}}
@@ -23,7 +23,7 @@ BAR=\${{environment.API_URL}}
BAZ=test
`;
const result = getEnvironmentVariablesObject(
const result = getEnviromentVariablesObject(
serviceEnv,
projectEnv,
environmentEnv,
@@ -45,7 +45,7 @@ DATABASE_URL=\${{project.DATABASE_URL}}
SERVICE_PORT=4000
`;
const result = getEnvironmentVariablesObject(
const result = getEnviromentVariablesObject(
serviceEnv,
projectEnv,
environmentEnv,
@@ -72,7 +72,7 @@ PASSWORD=secret123
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
`;
const result = getEnvironmentVariablesObject(serviceEnv, "", multiRefEnv);
const result = getEnviromentVariablesObject(serviceEnv, "", multiRefEnv);
expect(result).toEqual({
DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
@@ -85,7 +85,7 @@ UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}}
`;
expect(() =>
getEnvironmentVariablesObject(serviceWithUndefined, "", environmentEnv),
getEnviromentVariablesObject(serviceWithUndefined, "", environmentEnv),
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
});
@@ -95,7 +95,7 @@ NODE_ENV=production
API_URL=\${{environment.API_URL}}
`;
const result = getEnvironmentVariablesObject(
const result = getEnviromentVariablesObject(
serviceOverrideEnv,
"",
environmentEnv,
@@ -115,7 +115,7 @@ SERVICE_NAME=my-service
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
`;
const result = getEnvironmentVariablesObject(
const result = getEnviromentVariablesObject(
complexServiceEnv,
projectEnv,
environmentEnv,
@@ -150,7 +150,7 @@ ENV_VAR=\${{environment.API_URL}}
DB_NAME=\${{environment.DATABASE_NAME}}
`;
const result = getEnvironmentVariablesObject(
const result = getEnviromentVariablesObject(
serviceWithConflicts,
conflictingProjectEnv,
conflictingEnvironmentEnv,
@@ -170,7 +170,7 @@ SERVICE_VAR=test
PROJECT_VAR=\${{project.ENVIRONMENT}}
`;
const result = getEnvironmentVariablesObject(
const result = getEnviromentVariablesObject(
serviceWithEmpty,
projectEnv,
"",
@@ -1,369 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
canEditDeployGitSource,
getAccessibleGitProviderIds,
} from "@dokploy/server/services/git-provider";
const mockDb = vi.hoisted(() => ({
query: {
gitProvider: {
findMany: vi.fn(),
findFirst: vi.fn(),
},
member: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@dokploy/server/db", () => ({ db: mockDb }));
const mockHasValidLicense = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: mockHasValidLicense,
}));
const ORG_ID = "org-1";
const USER_OWNER = "user-owner";
const USER_ADMIN = "user-admin";
const USER_MEMBER = "user-member";
const USER_MEMBER_2 = "user-member-2";
const providerOwned = {
gitProviderId: "gp-owned",
userId: USER_MEMBER,
sharedWithOrganization: false,
};
const providerShared = {
gitProviderId: "gp-shared",
userId: USER_OWNER,
sharedWithOrganization: true,
};
const providerPrivate = {
gitProviderId: "gp-private",
userId: USER_OWNER,
sharedWithOrganization: false,
};
const providerOtherMember = {
gitProviderId: "gp-other",
userId: USER_MEMBER_2,
sharedWithOrganization: false,
};
const allProviders = [
providerOwned,
providerShared,
providerPrivate,
providerOtherMember,
];
function session(userId: string) {
return { userId, activeOrganizationId: ORG_ID };
}
beforeEach(() => {
vi.clearAllMocks();
mockDb.query.gitProvider.findMany.mockResolvedValue(allProviders);
mockHasValidLicense.mockResolvedValue(false);
});
describe("getAccessibleGitProviderIds", () => {
describe("owner", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "owner",
accessedGitProviders: [],
});
});
it("returns all org providers", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_OWNER));
expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId)));
});
it("includes providers owned by other members", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_OWNER));
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
expect(ids.has(providerOtherMember.gitProviderId)).toBe(true);
});
});
describe("admin", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "admin",
accessedGitProviders: [],
});
});
it("returns all org providers", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId)));
});
it("includes providers owned by other members — fixes issue #4469", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
expect(ids.has(providerOtherMember.gitProviderId)).toBe(true);
});
});
describe("member without enterprise license", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [providerPrivate.gitProviderId],
});
mockHasValidLicense.mockResolvedValue(false);
});
it("can access their own provider", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
});
it("can access shared providers", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerShared.gitProviderId)).toBe(true);
});
it("cannot access private providers of other users even if assigned (no license)", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
});
it("cannot access providers of other members", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
});
});
describe("member with enterprise license", () => {
beforeEach(() => {
mockHasValidLicense.mockResolvedValue(true);
});
it("can access provider explicitly assigned to them", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [providerPrivate.gitProviderId],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
});
it("cannot access provider not assigned and not shared", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
});
it("can access shared provider even without explicit assignment", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerShared.gitProviderId)).toBe(true);
});
it("can access own provider regardless of assignments", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
});
it("cannot access provider of other member even with license but no assignment", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
});
});
describe("member with no member record", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue(null);
mockHasValidLicense.mockResolvedValue(true);
});
it("only returns own providers and shared ones", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
expect(ids.has(providerShared.gitProviderId)).toBe(true);
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
});
});
describe("enterprise license — member assigned to a provider they do not own", () => {
// getAccessibleGitProviderIds still returns the provider (member can connect NEW deploys)
it("member assigned to owner's private provider can USE the provider for new deploys", async () => {
mockHasValidLicense.mockResolvedValue(true);
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [providerPrivate.gitProviderId],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
});
it("member NOT assigned to owner's private provider cannot use it at all", async () => {
mockHasValidLicense.mockResolvedValue(true);
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
});
});
describe("empty org", () => {
beforeEach(() => {
mockDb.query.gitProvider.findMany.mockResolvedValue([]);
mockDb.query.member.findFirst.mockResolvedValue({
role: "admin",
accessedGitProviders: [],
});
});
it("returns empty set when org has no providers", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
expect(ids.size).toBe(0);
});
});
});
describe("canEditDeployGitSource", () => {
beforeEach(() => {
vi.clearAllMocks();
mockHasValidLicense.mockResolvedValue(true);
});
describe("owner", () => {
it("can edit deploy using any provider", async () => {
mockDb.query.member.findFirst.mockResolvedValue({ role: "owner" });
const result = await canEditDeployGitSource(
providerPrivate.gitProviderId,
session(USER_OWNER),
);
expect(result).toBe(true);
});
});
describe("admin", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({ role: "admin" });
});
it("cannot edit deploy using owner's private provider (not shared)", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_OWNER,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
providerPrivate.gitProviderId,
session(USER_ADMIN),
);
expect(result).toBe(false);
});
it("can edit deploy using a provider shared with the org", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_OWNER,
sharedWithOrganization: true,
});
const result = await canEditDeployGitSource(
providerShared.gitProviderId,
session(USER_ADMIN),
);
expect(result).toBe(true);
});
it("can edit deploy using their own provider", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_ADMIN,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
"gp-admin-owned",
session(USER_ADMIN),
);
expect(result).toBe(true);
});
});
describe("member", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({ role: "member" });
});
it("can edit deploy using their own provider", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_MEMBER,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
providerOwned.gitProviderId,
session(USER_MEMBER),
);
expect(result).toBe(true);
});
it("can edit deploy using a provider shared with the org", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_OWNER,
sharedWithOrganization: true,
});
const result = await canEditDeployGitSource(
providerShared.gitProviderId,
session(USER_MEMBER),
);
expect(result).toBe(true);
});
it("cannot edit deploy using owner's private provider even with enterprise license and assignment", async () => {
// This is the key case: enterprise, provider del owner, no compartido,
// member tiene accessedGitProviders asignado — pero NO puede cambiar la branch del deploy del owner
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_OWNER,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
providerPrivate.gitProviderId,
session(USER_MEMBER),
);
expect(result).toBe(false);
});
it("cannot edit deploy using another member's private provider", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_MEMBER_2,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
providerOtherMember.gitProviderId,
session(USER_MEMBER),
);
expect(result).toBe(false);
});
it("returns false if provider does not exist", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue(null);
const result = await canEditDeployGitSource(
"nonexistent-id",
session(USER_MEMBER),
);
expect(result).toBe(false);
});
});
});
@@ -1,186 +0,0 @@
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("owner and admin 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("owner bypasses multiple enterprise permissions at once", async () => {
memberToReturn = mockMemberData("owner");
await expect(
checkPermission(ctx, {
deployment: ["read"],
backup: ["create"],
domain: ["delete"],
}),
).resolves.toBeUndefined();
});
});
describe("member is denied org-level enterprise resources (CVE: bypass via staticRoles)", () => {
it("member is denied registry.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { registry: ["read"] }),
).rejects.toThrow();
});
it("member is denied certificate.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { certificate: ["read"] }),
).rejects.toThrow();
});
it("member is denied destination.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { destination: ["read"] }),
).rejects.toThrow();
});
it("member is denied notification.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { notification: ["read"] }),
).rejects.toThrow();
});
it("member is denied auditLog.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { auditLog: ["read"] }),
).rejects.toThrow();
});
it("member is denied server.read", async () => {
memberToReturn = mockMemberData("member");
await expect(checkPermission(ctx, { server: ["read"] })).rejects.toThrow();
});
it("member is denied registry.create", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { registry: ["create"] }),
).rejects.toThrow();
});
});
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();
});
});
@@ -1,79 +0,0 @@
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);
}
});
});
@@ -1,161 +0,0 @@
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);
});
});
@@ -1,132 +0,0 @@
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();
});
});
@@ -57,7 +57,7 @@ const createApplication = (
env: null,
},
replicas: 1,
stopGracePeriodSwarm: 0,
stopGracePeriodSwarm: 0n,
ulimitsSwarm: null,
serverId: "server-id",
...overrides,
@@ -76,8 +76,8 @@ describe("mechanizeDockerContainer", () => {
});
});
it("passes stopGracePeriodSwarm as a number and keeps zero values", async () => {
const application = createApplication({ stopGracePeriodSwarm: 0 });
it("converts bigint stopGracePeriodSwarm to a number and keeps zero values", async () => {
const application = createApplication({ stopGracePeriodSwarm: 0n });
await mechanizeDockerContainer(application);
@@ -494,49 +494,4 @@ describe("processTemplate", () => {
expect(result.mounts).toHaveLength(1);
});
});
describe("isolated deployment config", () => {
it("should default to isolated=true when not specified", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [],
env: {},
},
};
expect(template.config.isolated).toBeUndefined();
// undefined !== false => isolatedDeployment = true
expect(template.config.isolated !== false).toBe(true);
});
it("should be isolated when isolated=true is explicitly set", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
isolated: true,
domains: [],
env: {},
},
};
expect(template.config.isolated !== false).toBe(true);
});
it("should disable isolated deployment when isolated=false", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
isolated: false,
domains: [],
env: {},
},
};
expect(template.config.isolated !== false).toBe(false);
});
});
});
@@ -30,7 +30,9 @@ describe("helpers functions", () => {
const domain = processValue("${domain}", {}, mockSchema);
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
expect(
domain.endsWith(`${mockSchema.serverIp.replaceAll(".", "-")}.sslip.io`),
domain.endsWith(
`${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`,
),
).toBeTruthy();
});
});
@@ -1,233 +0,0 @@
import type { ApplicationNested, Domain } from "@dokploy/server";
import {
buildForwardAuthEnv,
createRouterConfig,
deriveBaseDomain,
deriveCookieSecret,
forwardAuthCallbackUrl,
forwardAuthMiddlewareName,
} from "@dokploy/server";
import { beforeAll, describe, expect, test } from "vitest";
const app = {
appName: "my-app",
redirects: [],
security: [],
} as unknown as ApplicationNested;
const baseDomain: Domain = {
applicationId: "app-1",
certificateType: "none",
createdAt: "",
domainId: "domain-1",
host: "app.example.com",
https: false,
path: null,
port: 3000,
customEntrypoint: null,
serviceName: "",
composeId: "",
customCertResolver: null,
domainType: "application",
uniqueConfigKey: 7,
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
};
describe("forwardAuthMiddlewareName", () => {
test("is stable and unique per app + uniqueConfigKey", () => {
expect(forwardAuthMiddlewareName("my-app", 7)).toBe(
"forward-auth-my-app-7",
);
expect(forwardAuthMiddlewareName("my-app", 7)).toBe(
forwardAuthMiddlewareName("my-app", 7),
);
expect(forwardAuthMiddlewareName("my-app", 7)).not.toBe(
forwardAuthMiddlewareName("my-app", 8),
);
});
});
describe("createRouterConfig forward-auth wiring", () => {
test("does NOT add forward-auth middleware when no provider is linked", async () => {
const config = await createRouterConfig(app, baseDomain, "websecure");
expect(config.middlewares).not.toContain(
forwardAuthMiddlewareName("my-app", 7),
);
});
test("adds forward-auth middleware when a provider is linked", async () => {
const domain: Domain = {
...baseDomain,
forwardAuthEnabled: true,
};
const config = await createRouterConfig(app, domain, "websecure");
expect(config.middlewares).toContain(
forwardAuthMiddlewareName("my-app", 7),
);
});
test("forward-auth runs before custom domain middlewares", async () => {
const domain: Domain = {
...baseDomain,
forwardAuthEnabled: true,
middlewares: ["rate-limit@file"],
};
const config = await createRouterConfig(app, domain, "websecure");
const forwardAuthIdx = config.middlewares?.indexOf(
forwardAuthMiddlewareName("my-app", 7),
);
const customIdx = config.middlewares?.indexOf("rate-limit@file");
expect(forwardAuthIdx).toBeGreaterThanOrEqual(0);
expect(customIdx).toBeGreaterThan(forwardAuthIdx as number);
});
test("redirect-only web router does not get the forward-auth middleware", async () => {
const domain: Domain = {
...baseDomain,
https: true,
forwardAuthEnabled: true,
};
const config = await createRouterConfig(app, domain, "web");
expect(config.middlewares).toContain("redirect-to-https");
expect(config.middlewares).not.toContain(
forwardAuthMiddlewareName("my-app", 7),
);
});
});
describe("buildForwardAuthEnv", () => {
const baseOptions = {
oidc: {
clientId: "client-123",
clientSecret: "secret-xyz",
issuer: "https://idp.example.com",
},
cookieSecret: "cookie-secret-value",
authDomain: "auth.acme.com",
baseDomain: ".acme.com",
authDomainHttps: true,
};
test("emits the required oauth2-proxy OIDC env vars", () => {
const env = buildForwardAuthEnv(baseOptions);
expect(env).toContain("OAUTH2_PROXY_PROVIDER=oidc");
expect(env).toContain(
"OAUTH2_PROXY_OIDC_ISSUER_URL=https://idp.example.com",
);
expect(env).toContain("OAUTH2_PROXY_CLIENT_ID=client-123");
expect(env).toContain("OAUTH2_PROXY_CLIENT_SECRET=secret-xyz");
expect(env).toContain("OAUTH2_PROXY_COOKIE_SECRET=cookie-secret-value");
expect(env).toContain("OAUTH2_PROXY_REVERSE_PROXY=true");
expect(env).toContain("OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:4180");
});
test("uses the central auth domain for the single fixed callback", () => {
const env = buildForwardAuthEnv(baseOptions);
expect(env).toContain(
"OAUTH2_PROXY_REDIRECT_URL=https://auth.acme.com/oauth2/callback",
);
});
test("shares cookie + whitelist on the base domain (no per-app redeploy)", () => {
const env = buildForwardAuthEnv(baseOptions);
expect(env).toContain("OAUTH2_PROXY_COOKIE_DOMAINS=.acme.com");
expect(env).toContain("OAUTH2_PROXY_WHITELIST_DOMAINS=.acme.com");
});
test("matches cookie Secure flag and callback scheme to https setting", () => {
const https = buildForwardAuthEnv(baseOptions);
expect(https).toContain("OAUTH2_PROXY_COOKIE_SECURE=true");
const http = buildForwardAuthEnv({
...baseOptions,
authDomainHttps: false,
});
expect(http).toContain("OAUTH2_PROXY_COOKIE_SECURE=false");
expect(http).toContain(
"OAUTH2_PROXY_REDIRECT_URL=http://auth.acme.com/oauth2/callback",
);
});
test("allows unverified emails so OIDC providers don't 500 the callback", () => {
const env = buildForwardAuthEnv(baseOptions);
expect(env).toContain(
"OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL=true",
);
});
test("defaults to any authenticated user and standard scopes", () => {
const env = buildForwardAuthEnv(baseOptions);
expect(env).toContain("OAUTH2_PROXY_EMAIL_DOMAINS=*");
expect(env).toContain("OAUTH2_PROXY_SCOPE=openid email profile");
});
test("honors custom scopes and email domains", () => {
const env = buildForwardAuthEnv({
...baseOptions,
oidc: { ...baseOptions.oidc, scopes: ["openid", "groups"] },
emailDomains: ["acme.com", "corp.com"],
});
expect(env).toContain("OAUTH2_PROXY_SCOPE=openid groups");
expect(env).toContain("OAUTH2_PROXY_EMAIL_DOMAINS=acme.com,corp.com");
});
test("sets skip-discovery flag only when requested", () => {
const withoutSkip = buildForwardAuthEnv(baseOptions);
expect(withoutSkip).not.toContain("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
const withSkip = buildForwardAuthEnv({
...baseOptions,
oidc: { ...baseOptions.oidc, skipDiscovery: true },
});
expect(withSkip).toContain("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
});
});
describe("deriveBaseDomain", () => {
test("strips the auth subdomain to the shared base", () => {
expect(deriveBaseDomain("auth.acme.com")).toBe(".acme.com");
expect(deriveBaseDomain("sso.apps.acme.com")).toBe(".apps.acme.com");
});
test("keeps a two-label apex as the base", () => {
expect(deriveBaseDomain("acme.com")).toBe(".acme.com");
});
});
describe("forwardAuthCallbackUrl", () => {
test("builds the single IdP callback per scheme", () => {
expect(forwardAuthCallbackUrl("auth.acme.com", true)).toBe(
"https://auth.acme.com/oauth2/callback",
);
expect(forwardAuthCallbackUrl("auth.acme.com", false)).toBe(
"http://auth.acme.com/oauth2/callback",
);
});
});
describe("deriveCookieSecret", () => {
beforeAll(() => {
process.env.BETTER_AUTH_SECRET = "test-root-secret";
});
test("is deterministic for the same salt (survives service updates)", () => {
expect(deriveCookieSecret(".acme.com")).toBe(
deriveCookieSecret(".acme.com"),
);
});
test("differs per salt", () => {
expect(deriveCookieSecret(".acme.com")).not.toBe(
deriveCookieSecret(".other.com"),
);
});
test("produces a 16-byte hex secret (oauth2-proxy requirement)", () => {
const secret = deriveCookieSecret(".acme.com");
expect(Buffer.from(secret, "hex")).toHaveLength(16);
});
});
@@ -48,25 +48,9 @@ const baseSettings: WebServerSettings = {
urlCallback: "",
},
},
whitelabelingConfig: {
appName: null,
appDescription: null,
logoUrl: null,
faviconUrl: null,
customCss: null,
loginLogoUrl: null,
supportUrl: null,
docsUrl: null,
errorPageTitle: null,
errorPageDescription: null,
metaTitle: null,
footerText: null,
},
cleanupCacheApplications: false,
cleanupCacheOnCompose: false,
cleanupCacheOnPreviews: false,
remoteServersOnly: false,
enforceSSO: false,
createdAt: null,
updatedAt: new Date(),
};
@@ -95,7 +95,6 @@ const baseApp: ApplicationNested = {
dropBuildPath: null,
enabled: null,
env: null,
icon: null,
healthCheckSwarm: null,
labelsSwarm: null,
memoryLimit: null,
@@ -138,7 +137,6 @@ const baseDomain: Domain = {
https: false,
path: null,
port: null,
customEntrypoint: null,
serviceName: "",
composeId: "",
customCertResolver: null,
@@ -147,8 +145,6 @@ const baseDomain: Domain = {
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
};
const baseRedirect: Redirect = {
@@ -268,80 +264,6 @@ test("Websecure entrypoint on https domain with redirect", async () => {
expect(router.middlewares).toContain("redirect-test-1");
});
/** Custom Middlewares */
test("Web entrypoint with single custom middleware", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, middlewares: ["auth@file"] },
"web",
);
expect(router.middlewares).toContain("auth@file");
});
test("Web entrypoint with multiple custom middlewares", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, middlewares: ["auth@file", "rate-limit@file"] },
"web",
);
expect(router.middlewares).toContain("auth@file");
expect(router.middlewares).toContain("rate-limit@file");
});
test("Web entrypoint on https domain with custom middleware", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
"web",
);
// Should only have HTTPS redirect - custom middleware applies on websecure
expect(router.middlewares).toContain("redirect-to-https");
expect(router.middlewares).not.toContain("auth@file");
});
test("Websecure entrypoint with custom middleware", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
"websecure",
);
// Should have custom middleware but not HTTPS redirect
expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.middlewares).toContain("auth@file");
});
test("Web entrypoint with redirect and custom middleware", async () => {
const router = await createRouterConfig(
{
...baseApp,
appName: "test",
redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }],
},
{ ...baseDomain, middlewares: ["auth@file"] },
"web",
);
// Should have both redirect middleware and custom middleware
expect(router.middlewares).toContain("redirect-test-1");
expect(router.middlewares).toContain("auth@file");
});
test("Web entrypoint with empty middlewares array", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: false, middlewares: [] },
"web",
);
// Should behave same as no middlewares - no redirect for http
expect(router.middlewares).not.toContain("redirect-to-https");
});
/** Certificates */
test("CertificateType on websecure entrypoint", async () => {
@@ -354,130 +276,6 @@ test("CertificateType on websecure entrypoint", async () => {
expect(router.tls?.certResolver).toBe("letsencrypt");
});
test("Custom entrypoint on http domain", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: false, customEntrypoint: "custom" },
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.tls).toBeUndefined();
});
test("Custom entrypoint on https domain", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
https: true,
customEntrypoint: "custom",
certificateType: "letsencrypt",
},
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.tls?.certResolver).toBe("letsencrypt");
});
test("Custom entrypoint with path includes PathPrefix in rule", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, customEntrypoint: "custom", path: "/api" },
"custom",
);
expect(router.rule).toContain("PathPrefix(`/api`)");
expect(router.entryPoints).toEqual(["custom"]);
});
test("Custom entrypoint with stripPath adds stripprefix middleware", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
},
"custom",
);
expect(router.middlewares).toContain("stripprefix--1");
expect(router.entryPoints).toEqual(["custom"]);
});
test("Custom entrypoint with internalPath adds addprefix middleware", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
customEntrypoint: "custom",
internalPath: "/hello",
},
"custom",
);
expect(router.middlewares).toContain("addprefix--1");
expect(router.entryPoints).toEqual(["custom"]);
});
test("stripPath and internalPath together: stripprefix must come before addprefix", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
path: "/public",
stripPath: true,
internalPath: "/app/v2",
},
"web",
);
const stripIndex = router.middlewares?.indexOf("stripprefix--1") ?? -1;
const addIndex = router.middlewares?.indexOf("addprefix--1") ?? -1;
expect(stripIndex).toBeGreaterThanOrEqual(0);
expect(addIndex).toBeGreaterThanOrEqual(0);
expect(stripIndex).toBeLessThan(addIndex);
});
test("Custom entrypoint with https and custom cert resolver", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
https: true,
customEntrypoint: "custom",
certificateType: "custom",
customCertResolver: "myresolver",
},
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.tls?.certResolver).toBe("myresolver");
});
test("Custom entrypoint without https should not have tls", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
https: false,
customEntrypoint: "custom",
certificateType: "letsencrypt",
},
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.tls).toBeUndefined();
});
/** IDN/Punycode */
test("Internationalized domain name is converted to punycode", async () => {
@@ -78,20 +78,4 @@ describe("readValidDirectory (path traversal)", () => {
it("returns false for empty string (resolves to cwd)", () => {
expect(readValidDirectory("")).toBe(false);
});
it("returns true for Next.js dynamic route paths with square brackets", () => {
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/app/api/[id]/route.ts`,
),
).toBe(true);
expect(
readValidDirectory(`${BASE}/applications/myapp/code/pages/[slug].tsx`),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/app/[...catch]/page.tsx`,
),
).toBe(true);
});
});
@@ -112,21 +112,14 @@ const menuItems: MenuItem[] = [
const hasStopGracePeriodSwarm = (
value: unknown,
): value is { stopGracePeriodSwarm: number | string | null } =>
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
typeof value === "object" &&
value !== null &&
"stopGracePeriodSwarm" in value;
interface Props {
id: string;
type:
| "application"
| "libsql"
| "mariadb"
| "mongo"
| "mysql"
| "postgres"
| "redis";
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const AddSwarmSettings = ({ id, type }: Props) => {
@@ -37,27 +37,27 @@ import { AddSwarmSettings } from "./modify-swarm-settings";
interface Props {
id: string;
type: "application" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis";
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
const AddRedirectSchema = z.object({
const AddRedirectchema = z.object({
replicas: z.number().min(1, "Replicas must be at least 1"),
registryId: z.string().optional(),
});
type AddCommand = z.infer<typeof AddRedirectSchema>;
type AddCommand = z.infer<typeof AddRedirectchema>;
export const ShowClusterSettings = ({ id, type }: Props) => {
const queryMap = {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -65,13 +65,12 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
const { data: registries } = api.registry.all.useQuery();
const mutationMap = {
application: () => api.application.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync, isPending } = mutationMap[type]
@@ -87,7 +86,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
: {}),
replicas: data?.replicas || 1,
},
resolver: zodResolver(AddRedirectSchema),
resolver: zodResolver(AddRedirectchema),
});
useEffect(() => {
@@ -106,11 +105,11 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
const onSubmit = async (data: AddCommand) => {
await mutateAsync({
applicationId: id || "",
mariadbId: id || "",
mongoId: id || "",
mysqlId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
...(type === "application"
? {
registryId:
@@ -28,14 +28,7 @@ export const endpointSpecFormSchema = z.object({
interface EndpointSpecFormProps {
id: string;
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
@@ -51,7 +44,6 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -64,7 +56,6 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
@@ -103,7 +94,6 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
endpointSpecSwarm: hasAnyValue ? formData : null,
});
@@ -16,29 +16,17 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const optionalNumber = z
.union([z.string(), z.number()])
.transform((val) => (val === "" ? undefined : Number(val)))
.optional();
export const healthCheckFormSchema = z.object({
Test: z.array(z.string()).optional(),
Interval: optionalNumber,
Timeout: optionalNumber,
StartPeriod: optionalNumber,
Retries: optionalNumber,
Interval: z.coerce.number().optional(),
Timeout: z.coerce.number().optional(),
StartPeriod: z.coerce.number().optional(),
Retries: z.coerce.number().optional(),
});
interface HealthCheckFormProps {
id: string;
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
@@ -54,7 +42,6 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -67,7 +54,6 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
@@ -118,7 +104,6 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
healthCheckSwarm: hasAnyValue ? formData : null,
});
@@ -200,12 +185,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
Time between health checks (e.g., 10000000000 for 10 seconds)
</FormDescription>
<FormControl>
<Input
type="number"
placeholder="10000000000"
{...field}
value={field.value ?? ""}
/>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -222,12 +202,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
Maximum time to wait for health check response
</FormDescription>
<FormControl>
<Input
type="number"
placeholder="10000000000"
{...field}
value={field.value ?? ""}
/>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -244,12 +219,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
Initial grace period before health checks begin
</FormDescription>
<FormControl>
<Input
type="number"
placeholder="10000000000"
{...field}
value={field.value ?? ""}
/>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -267,12 +237,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
unhealthy
</FormDescription>
<FormControl>
<Input
type="number"
placeholder="3"
{...field}
value={field.value ?? ""}
/>
<Input type="number" placeholder="3" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -29,14 +29,7 @@ export const labelsFormSchema = z.object({
interface LabelsFormProps {
id: string;
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const LabelsForm = ({ id, type }: LabelsFormProps) => {
@@ -52,7 +45,6 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -65,7 +57,6 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
@@ -121,7 +112,6 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
labelsSwarm: labelsToSend,
});
@@ -23,14 +23,7 @@ import { api } from "@/utils/api";
interface ModeFormProps {
id: string;
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const ModeForm = ({ id, type }: ModeFormProps) => {
@@ -46,7 +39,6 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -59,7 +51,6 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
@@ -104,7 +95,6 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
modeSwarm: null,
});
toast.success("Mode updated successfully");
@@ -132,7 +122,6 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
modeSwarm: modeData,
});
@@ -35,14 +35,7 @@ export const networkFormSchema = z.object({
interface NetworkFormProps {
id: string;
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const NetworkForm = ({ id, type }: NetworkFormProps) => {
@@ -58,7 +51,6 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -71,7 +63,6 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
@@ -141,7 +132,6 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
networkSwarm: networksToSend,
});
@@ -34,14 +34,7 @@ export const placementFormSchema = z.object({
interface PlacementFormProps {
id: string;
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const PlacementForm = ({ id, type }: PlacementFormProps) => {
@@ -57,7 +50,6 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -70,7 +62,6 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
@@ -123,7 +114,6 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
placementSwarm: hasAnyValue
? {
...formData,
@@ -32,14 +32,7 @@ export const restartPolicyFormSchema = z.object({
interface RestartPolicyFormProps {
id: string;
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
@@ -55,7 +48,6 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -68,7 +60,6 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
@@ -113,7 +104,6 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
restartPolicySwarm: hasAnyValue ? formData : null,
});
@@ -34,14 +34,7 @@ export const rollbackConfigFormSchema = z.object({
interface RollbackConfigFormProps {
id: string;
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
@@ -57,7 +50,6 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -70,7 +62,6 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
@@ -112,7 +103,6 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
rollbackConfigSwarm: (hasAnyValue ? formData : null) as any,
});
@@ -16,21 +16,14 @@ import { api } from "@/utils/api";
const hasStopGracePeriodSwarm = (
value: unknown,
): value is { stopGracePeriodSwarm: number | string | null } =>
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
typeof value === "object" &&
value !== null &&
"stopGracePeriodSwarm" in value;
interface StopGracePeriodFormProps {
id: string;
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
@@ -46,7 +39,6 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -59,7 +51,6 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
@@ -68,7 +59,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
const form = useForm<any>({
defaultValues: {
value: null as number | null,
value: null as bigint | null,
},
});
@@ -76,7 +67,11 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
if (hasStopGracePeriodSwarm(data)) {
const value = data.stopGracePeriodSwarm;
const normalizedValue =
value === null || value === undefined ? null : Number(value);
value === null || value === undefined
? null
: typeof value === "bigint"
? value
: BigInt(value);
form.reset({
value: normalizedValue,
});
@@ -93,7 +88,6 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
stopGracePeriodSwarm: formData.value,
});
@@ -132,7 +126,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
}
onChange={(e) =>
field.onChange(
e.target.value ? Number(e.target.value) : null,
e.target.value ? BigInt(e.target.value) : null,
)
}
/>
@@ -34,14 +34,7 @@ export const updateConfigFormSchema = z.object({
interface UpdateConfigFormProps {
id: string;
type:
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
@@ -57,7 +50,6 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -70,7 +62,6 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
@@ -118,7 +109,6 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
libsqlId: id || "",
updateConfigSwarm: (hasAnyValue ? formData : null) as any,
});
@@ -37,13 +37,13 @@ import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
const AddRedirectSchema = z.object({
const AddRedirectchema = z.object({
regex: z.string().min(1, "Regex required"),
permanent: z.boolean().default(false),
replacement: z.string().min(1, "Replacement required"),
});
type AddRedirect = z.infer<typeof AddRedirectSchema>;
type AddRedirect = z.infer<typeof AddRedirectchema>;
// Default presets
const redirectPresets = [
@@ -110,7 +110,7 @@ export const HandleRedirect = ({
regex: "",
replacement: "",
},
resolver: zodResolver(AddRedirectSchema),
resolver: zodResolver(AddRedirectchema),
});
useEffect(() => {
@@ -149,7 +149,7 @@ export const HandleRedirect = ({
const onDialogToggle = (open: boolean) => {
setIsOpen(open);
// commented for the moment because not resetting the form if accidentally closed the dialog can be considered as a feature instead of a bug
// commented for the moment because not reseting the form if accidentally closed the dialog can be considered as a feature instead of a bug
// setPresetSelected("");
// form.reset();
};
@@ -89,13 +89,12 @@ const ULIMIT_PRESETS = [
];
export type ServiceType =
| "application"
| "libsql"
| "mariadb"
| "mongo"
| "mysql"
| "postgres"
| "redis";
| "mongo"
| "redis"
| "mysql"
| "mariadb"
| "application";
interface Props {
id: string;
@@ -106,29 +105,27 @@ type AddResources = z.infer<typeof addResourcesSchema>;
export const ShowResources = ({ id, type }: Props) => {
const queryMap = {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
application: () => api.application.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync, isPending } = mutationMap[type]
@@ -158,20 +155,19 @@ export const ShowResources = ({ id, type }: Props) => {
cpuReservation: data?.cpuReservation || undefined,
memoryLimit: data?.memoryLimit || undefined,
memoryReservation: data?.memoryReservation || undefined,
ulimitsSwarm: (data as any)?.ulimitsSwarm || [],
ulimitsSwarm: data?.ulimitsSwarm || [],
});
}
}, [data, form, form.reset]);
const onSubmit = async (formData: AddResources) => {
await mutateAsync({
applicationId: id || "",
libsqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
mysqlId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
applicationId: id || "",
cpuLimit: formData.cpuLimit || null,
cpuReservation: formData.cpuReservation || null,
memoryLimit: formData.memoryLimit || null,
@@ -224,7 +220,7 @@ export const ShowResources = ({ id, type }: Props) => {
<FormLabel>Memory Limit</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger type="button">
<TooltipTrigger>
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
@@ -263,7 +259,7 @@ export const ShowResources = ({ id, type }: Props) => {
<FormLabel>Memory Reservation</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger type="button">
<TooltipTrigger>
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
@@ -303,7 +299,7 @@ export const ShowResources = ({ id, type }: Props) => {
<FormLabel>CPU Limit</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger type="button">
<TooltipTrigger>
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
@@ -343,7 +339,7 @@ export const ShowResources = ({ id, type }: Props) => {
<FormLabel>CPU Reservation</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger type="button">
<TooltipTrigger>
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
@@ -379,7 +375,7 @@ export const ShowResources = ({ id, type }: Props) => {
<FormLabel className="text-base">Ulimits</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger type="button">
<TooltipTrigger>
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
@@ -15,17 +15,13 @@ interface Props {
}
export const ShowTraefikConfig = ({ applicationId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canRead = permissions?.traefikFiles.read ?? false;
const { data, isPending } = api.application.readTraefikConfig.useQuery(
{
applicationId,
},
{ enabled: !!applicationId && canRead },
{ enabled: !!applicationId },
);
if (!canRead) return null;
return (
<Card className="bg-background">
<CardHeader className="flex flex-row justify-between">
@@ -60,8 +60,6 @@ export const validateAndFormatYAML = (yamlText: string) => {
};
export const UpdateTraefikConfig = ({ applicationId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canWrite = permissions?.traefikFiles.write ?? false;
const [open, setOpen] = useState(false);
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
const { data, refetch } = api.application.readTraefikConfig.useQuery(
@@ -127,11 +125,9 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
}
}}
>
{canWrite && (
<DialogTrigger asChild>
<Button isLoading={isPending}>Modify</Button>
</DialogTrigger>
)}
<DialogTrigger asChild>
<Button isLoading={isPending}>Modify</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Update traefik config</DialogTitle>
@@ -34,13 +34,13 @@ interface Props {
serviceId: string;
serviceType:
| "application"
| "compose"
| "libsql"
| "mariadb"
| "mongo"
| "mysql"
| "postgres"
| "redis";
| "redis"
| "mongo"
| "redis"
| "mysql"
| "mariadb"
| "compose";
refetch: () => void;
children?: React.ReactNode;
}
@@ -21,33 +21,24 @@ interface Props {
}
export const ShowVolumes = ({ id, type }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canRead = permissions?.volume.read ?? false;
const canCreate = permissions?.volume.create ?? false;
const canDelete = permissions?.volume.delete ?? false;
if (!canRead) return null;
const queryMap = {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
compose: () =>
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
compose: () =>
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const { mutateAsync: deleteVolume, isPending: isRemoving } =
api.mounts.remove.useMutation();
return (
<Card className="bg-background">
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
@@ -59,7 +50,7 @@ export const ShowVolumes = ({ id, type }: Props) => {
</CardDescription>
</div>
{canCreate && data && data?.mounts.length > 0 && (
{data && data?.mounts.length > 0 && (
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
Add Volume
</AddVolumes>
@@ -72,11 +63,9 @@ export const ShowVolumes = ({ id, type }: Props) => {
<span className="text-base text-muted-foreground">
No volumes/mounts configured
</span>
{canCreate && (
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
Add Volume
</AddVolumes>
)}
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
Add Volume
</AddVolumes>
</div>
) : (
<div className="flex flex-col pt-2 gap-4">
@@ -141,42 +130,38 @@ export const ShowVolumes = ({ id, type }: Props) => {
</div>
</div>
<div className="flex flex-row gap-1">
{canCreate && (
<UpdateVolume
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
serviceType={type}
/>
)}
{canDelete && (
<DialogAction
title="Delete Volume"
description="Are you sure you want to delete this volume?"
type="destructive"
onClick={async () => {
await deleteVolume({
mountId: mount.mountId,
<UpdateVolume
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
serviceType={type}
/>
<DialogAction
title="Delete Volume"
description="Are you sure you want to delete this volume?"
type="destructive"
onClick={async () => {
await deleteVolume({
mountId: mount.mountId,
})
.then(() => {
refetch();
toast.success("Volume deleted successfully");
})
.then(() => {
refetch();
toast.success("Volume deleted successfully");
})
.catch(() => {
toast.error("Error deleting volume");
});
}}
.catch(() => {
toast.error("Error deleting volume");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
</div>
@@ -67,13 +67,13 @@ interface Props {
refetch: () => void;
serviceType:
| "application"
| "compose"
| "libsql"
| "mariadb"
| "mongo"
| "mysql"
| "postgres"
| "redis";
| "redis"
| "mongo"
| "redis"
| "mysql"
| "mariadb"
| "compose";
}
export const UpdateVolume = ({
@@ -253,7 +253,7 @@ export const UpdateVolume = ({
control={form.control}
name="content"
render={({ field }) => (
<FormItem className="w-full max-w-[45rem]">
<FormItem className="max-w-full max-w-[45rem]">
<FormLabel>Content</FormLabel>
<FormControl>
<FormControl>
@@ -1,7 +1,6 @@
import copy from "copy-to-clipboard";
import { Check, Copy, Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { AnalyzeLogs } from "@/components/dashboard/docker/logs/analyze-logs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -166,7 +165,6 @@ export const ShowDeployment = ({
<Copy className="h-3.5 w-3.5" />
)}
</Button>
<AnalyzeLogs logs={filteredLogs} context="build" />
{serverId && (
<div className="flex items-center space-x-2">
@@ -1,9 +1,7 @@
import copy from "copy-to-clipboard";
import {
ChevronDown,
ChevronUp,
Clock,
Copy,
Loader2,
RefreshCcw,
RocketIcon,
@@ -99,12 +97,6 @@ export const ShowDeployments = ({
new Set(),
);
const webhookUrl = useMemo(
() =>
`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`,
[url, refreshToken, type],
);
const MAX_DESCRIPTION_LENGTH = 200;
const truncateDescription = (description: string): string => {
@@ -232,27 +224,11 @@ export const ShowDeployments = ({
<div className="flex flex-row items-center gap-2 flex-wrap">
<span>Webhook URL: </span>
<div className="flex flex-row items-center gap-2">
<Badge
role="button"
tabIndex={0}
aria-label="Copy webhook URL to clipboard"
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer whitespace-normal break-all"
variant="outline"
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
copy(webhookUrl);
toast.success("Copied to clipboard.");
}
}}
onClick={() => {
copy(webhookUrl);
toast.success("Copied to clipboard.");
}}
>
{webhookUrl}
<Copy className="h-4 w-4 ml-2" />
</Badge>
<span className="break-all text-muted-foreground">
{`${url}/api/deploy${
type === "compose" ? "/compose" : ""
}/${refreshToken}`}
</span>
{(type === "application" || type === "compose") && (
<RefreshToken id={id} type={type} />
)}
@@ -1,303 +0,0 @@
import type { ColumnDef } from "@tanstack/react-table";
import {
ArrowUpDown,
CheckCircle2,
ExternalLink,
Loader2,
PenBoxIcon,
RefreshCw,
Server,
Trash2,
XCircle,
} from "lucide-react";
import Link from "next/link";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { RouterOutputs } from "@/utils/api";
import { DnsHelperModal } from "./dns-helper-modal";
import { AddDomain } from "./handle-domain";
import type { ValidationStates } from "./show-domains";
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("sslip.io") && (
<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("sslip.io") && (
<DnsHelperModal
domain={{
host: domain.host,
https: domain.https,
path: domain.path || undefined,
}}
serverIp={serverIp}
/>
)}
{canCreateDomain && (
<AddDomain id={id} type={type} domainId={domain.domainId}>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 h-8 w-8"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</AddDomain>
)}
{canDeleteDomain && (
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await handleDeleteDomain(domain.domainId);
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 h-8 w-8"
isLoading={isDeleting}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
);
},
},
];
@@ -1,12 +1,11 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { DatabaseZap, Dices, RefreshCw, X } from "lucide-react";
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import z from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -62,14 +61,11 @@ export const domain = z
.min(1, { message: "Port must be at least 1" })
.max(65535, { message: "Port must be 65535 or below" })
.optional(),
useCustomEntrypoint: z.boolean(),
customEntrypoint: z.string().optional(),
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
customCertResolver: z.string().optional(),
serviceName: z.string().optional(),
domainType: z.enum(["application", "compose", "preview"]).optional(),
middlewares: z.array(z.string()).optional(),
})
.superRefine((input, ctx) => {
if (input.https && !input.certificateType) {
@@ -118,14 +114,6 @@ export const domain = z
message: "Internal path must start with '/'",
});
}
if (input.useCustomEntrypoint && !input.customEntrypoint) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["customEntrypoint"],
message: "Custom entry point must be specified",
});
}
});
type Domain = z.infer<typeof domain>;
@@ -208,24 +196,20 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
internalPath: undefined,
stripPath: false,
port: undefined,
useCustomEntrypoint: false,
customEntrypoint: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
serviceName: undefined,
domainType: type,
middlewares: [],
},
mode: "onChange",
});
const certificateType = form.watch("certificateType");
const useCustomEntrypoint = form.watch("useCustomEntrypoint");
const https = form.watch("https");
const domainType = form.watch("domainType");
const host = form.watch("host");
const isTraefikMeDomain = host?.includes("sslip.io") || false;
const isTraefikMeDomain = host?.includes("traefik.me") || false;
useEffect(() => {
if (data) {
@@ -236,13 +220,10 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
internalPath: data?.internalPath || undefined,
stripPath: data?.stripPath || false,
port: data?.port || undefined,
useCustomEntrypoint: !!data.customEntrypoint,
customEntrypoint: data.customEntrypoint || undefined,
certificateType: data?.certificateType || undefined,
customCertResolver: data?.customCertResolver || undefined,
serviceName: data?.serviceName || undefined,
domainType: data?.domainType || type,
middlewares: data?.middlewares || [],
});
}
@@ -253,13 +234,10 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
internalPath: undefined,
stripPath: false,
port: undefined,
useCustomEntrypoint: false,
customEntrypoint: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
domainType: type,
middlewares: [],
});
}
}, [form, data, isPending, domainId]);
@@ -290,7 +268,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
composeId: id,
}),
...data,
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
})
.then(async () => {
toast.success(dictionary.success);
@@ -513,7 +490,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
render={({ field }) => (
<FormItem>
{!canGenerateTraefikMeDomains &&
field.value.includes("sslip.io") && (
field.value.includes("traefik.me") && (
<AlertBlock type="warning">
You need to set an IP address in your{" "}
<Link
@@ -524,12 +501,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"}
</Link>{" "}
to make your sslip.io domain work.
to make your traefik.me domain work.
</AlertBlock>
)}
{isTraefikMeDomain && (
<AlertBlock type="info">
<strong>Note:</strong> sslip.io is a public HTTP
<strong>Note:</strong> traefik.me is a public HTTP
service and does not support SSL/HTTPS. HTTPS and
certificate options will not have any effect.
</AlertBlock>
@@ -567,7 +544,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
sideOffset={5}
className="max-w-[10rem]"
>
<p>Generate sslip.io domain</p>
<p>Generate traefik.me domain</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -658,55 +635,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
}}
/>
<FormField
control={form.control}
name="useCustomEntrypoint"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>Custom Entrypoint</FormLabel>
<FormDescription>
Use custom entrypoint for domain
<br />
"web" and/or "websecure" is used by default.
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {
form.setValue("customEntrypoint", undefined);
}
}}
/>
</FormControl>
</FormItem>
)}
/>
{useCustomEntrypoint && (
<FormField
control={form.control}
name="customEntrypoint"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Entrypoint Name</FormLabel>
<FormControl>
<Input
placeholder="Enter entrypoint name manually"
{...field}
className="w-full"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="https"
@@ -797,88 +725,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
)}
</>
)}
<FormField
control={form.control}
name="middlewares"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>Middlewares</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger type="button">
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p>
Add Traefik middleware references. Middlewares
must be defined in your Traefik configuration.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((name, index) => (
<Badge key={index} variant="secondary">
{name}
<X
className="ml-1 size-3 cursor-pointer"
onClick={() => {
const newMiddlewares = [...(field.value || [])];
newMiddlewares.splice(index, 1);
form.setValue("middlewares", newMiddlewares);
}}
/>
</Badge>
))}
</div>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="e.g., rate-limit@file, auth@file"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const value = input.value.trim();
if (value && !field.value?.includes(value)) {
form.setValue("middlewares", [
...(field.value || []),
value,
]);
input.value = "";
}
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="e.g., rate-limit@file, auth@file"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value && !field.value?.includes(value)) {
form.setValue("middlewares", [
...(field.value || []),
value,
]);
input.value = "";
}
}}
>
Add
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</form>
@@ -1,147 +0,0 @@
import { ShieldCheck } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
interface Props {
domainId: string;
applicationId: string;
}
export const HandleForwardAuth = ({ domainId, applicationId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data: haveValidLicense } =
api.licenseKey.haveValidLicenseKey.useQuery();
const utils = api.useUtils();
const { data: status } = api.forwardAuth.status.useQuery(
{ domainId },
{ enabled: isOpen },
);
const { mutateAsync: enable, isPending: isEnabling } =
api.forwardAuth.enable.useMutation();
const { mutateAsync: disable, isPending: isDisabling } =
api.forwardAuth.disable.useMutation();
if (!haveValidLicense) {
return null;
}
const isEnabled = !!status?.enabled;
const isPending = isEnabling || isDisabling;
const refresh = async () => {
await utils.forwardAuth.status.invalidate({ domainId });
await utils.domain.byApplicationId.invalidate({ applicationId });
await utils.application.readTraefikConfig.invalidate({ applicationId });
};
const handleToggle = async (next: boolean) => {
try {
if (next) {
await enable({ domainId });
toast.success("SSO authentication enabled for this domain");
} else {
await disable({ domainId });
toast.success("SSO authentication disabled for this domain");
}
await refresh();
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Error updating SSO authentication",
);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group hover:bg-emerald-500/10"
title="SSO authentication"
>
<ShieldCheck
className={`size-4 ${
isEnabled
? "text-emerald-500"
: "text-primary group-hover:text-emerald-500"
}`}
/>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>SSO Authentication</DialogTitle>
<DialogDescription>
Require visitors to authenticate against your identity provider
before reaching this application.
</DialogDescription>
</DialogHeader>
<AlertBlock type="warning">
<div className="flex flex-col gap-1">
<span className="font-medium">Requirements</span>
<ol className="list-decimal pl-4 text-sm">
<li>
The authentication proxy container must be deployed and running
on this app's server. Configure it under{" "}
<span className="font-medium">
Settings SSO Application Authentication
</span>
.
</li>
<li>
This domain must share the same base domain as the
authentication domain (e.g. <code>app.acme.com</code> and{" "}
<code>auth.acme.com</code>).
</li>
</ol>
</div>
</AlertBlock>
<div className="flex items-center justify-between rounded-lg border p-4 mt-2">
<div className="flex flex-col">
<span className="text-sm font-medium">
Protect this domain with SSO
</span>
<span className="text-xs text-muted-foreground">
{isEnabled
? "Visitors must log in via your identity provider."
: "The domain is publicly accessible."}
</span>
</div>
<Switch
checked={isEnabled}
disabled={isPending}
onCheckedChange={handleToggle}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,22 +1,8 @@
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import {
CheckCircle2,
ChevronDown,
ExternalLink,
GlobeIcon,
InfoIcon,
LayoutGrid,
LayoutList,
Loader2,
PenBoxIcon,
RefreshCw,
@@ -37,21 +23,6 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
@@ -59,10 +30,8 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { createColumns } from "./columns";
import { DnsHelperModal } from "./dns-helper-modal";
import { AddDomain } from "./handle-domain";
import { HandleForwardAuth } from "./handle-forward-auth";
export type ValidationState = {
isLoading: boolean;
@@ -81,9 +50,6 @@ interface Props {
}
export const ShowDomains = ({ id, type }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canCreateDomain = permissions?.domain.create ?? false;
const canDeleteDomain = permissions?.domain.delete ?? false;
const { data: application } =
type === "application"
? api.application.one.useQuery(
@@ -105,19 +71,6 @@ export const ShowDomains = ({ id, type }: Props) => {
const [validationStates, setValidationStates] = useState<ValidationStates>(
{},
);
const [viewMode, setViewMode] = useState<"grid" | "table">(() => {
if (typeof window !== "undefined") {
return (
(localStorage.getItem("domains-view-mode") as "grid" | "table") ??
"grid"
);
}
return "grid";
});
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
const { data: ip } = api.settings.getIp.useQuery();
const {
@@ -147,16 +100,6 @@ export const ShowDomains = ({ id, type }: Props) => {
const { mutateAsync: deleteDomain, isPending: isRemoving } =
api.domain.delete.useMutation();
const handleDeleteDomain = async (domainId: string) => {
try {
await deleteDomain({ domainId });
refetch();
toast.success("Domain deleted successfully");
} catch {
toast.error("Error deleting domain");
}
};
const handleValidateDomain = async (host: string) => {
setValidationStates((prev) => ({
...prev,
@@ -194,37 +137,6 @@ export const ShowDomains = ({ id, type }: Props) => {
}
};
const columns = createColumns({
id,
type,
validationStates,
handleValidateDomain,
handleDeleteDomain,
isDeleting: isRemoving,
serverIp: application?.server?.ipAddress?.toString() || ip?.toString(),
canCreateDomain,
canDeleteDomain,
});
const table = useReactTable({
data: data ?? [],
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
return (
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
@@ -236,32 +148,13 @@ export const ShowDomains = ({ id, type }: Props) => {
</CardDescription>
</div>
<div className="flex flex-row gap-2 flex-wrap">
<div className="flex flex-row gap-4 flex-wrap">
{data && data?.length > 0 && (
<>
<Button
variant="outline"
size="icon"
onClick={() => {
const next = viewMode === "grid" ? "table" : "grid";
localStorage.setItem("domains-view-mode", next);
setViewMode(next);
}}
>
{viewMode === "grid" ? (
<LayoutList className="size-4" />
) : (
<LayoutGrid className="size-4" />
)}
<AddDomain id={id} type={type}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
{canCreateDomain && (
<AddDomain id={id} type={type}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
</AddDomain>
)}
</>
</AddDomain>
)}
</div>
</CardHeader>
@@ -280,131 +173,13 @@ export const ShowDomains = ({ id, type }: Props) => {
To access the application it is required to set at least 1
domain
</span>
{canCreateDomain && (
<div className="flex flex-row gap-4 flex-wrap">
<AddDomain id={id} type={type}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
</AddDomain>
</div>
)}
</div>
) : viewMode === "table" ? (
<div className="flex flex-col gap-4 w-full">
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by host..."
value={
(table.getColumn("host")?.getFilterValue() as string) ?? ""
}
onChange={(event) =>
table.getColumn("host")?.setFilterValue(event.target.value)
}
className="md:max-w-sm"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="sm:ml-auto max-sm:w-full"
>
Columns <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<div className="flex flex-row gap-4 flex-wrap">
<AddDomain id={id} type={type}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
</AddDomain>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table?.getRowModel()?.rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{data && data?.length > 0 && (
<div className="flex items-center justify-end space-x-2 py-4">
<div className="space-x-2 flex flex-wrap">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
)}
</div>
) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
@@ -426,7 +201,7 @@ export const ShowDomains = ({ id, type }: Props) => {
</Badge>
)}
<div className="flex gap-2 flex-wrap">
{!item.host.includes("sslip.io") && (
{!item.host.includes("traefik.me") && (
<DnsHelperModal
domain={{
host: item.host,
@@ -439,57 +214,47 @@ export const ShowDomains = ({ id, type }: Props) => {
}
/>
)}
{canCreateDomain && (
<AddDomain
id={id}
type={type}
domainId={item.domainId}
<AddDomain
id={id}
type={type}
domainId={item.domainId}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</AddDomain>
)}
{canCreateDomain && type === "application" && (
<HandleForwardAuth
domainId={item.domainId}
applicationId={id}
/>
)}
{canDeleteDomain && (
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await deleteDomain({
domainId: item.domainId,
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</AddDomain>
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await deleteDomain({
domainId: item.domainId,
})
.then((_data) => {
refetch();
toast.success(
"Domain deleted successfully",
);
})
.then((_data) => {
refetch();
toast.success(
"Domain deleted successfully",
);
})
.catch(() => {
toast.error("Error deleting domain");
});
}}
.catch(() => {
toast.error("Error deleting domain");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
<div className="w-full break-all">
@@ -567,22 +332,6 @@ export const ShowDomains = ({ id, type }: Props) => {
</TooltipProvider>
)}
{item.middlewares?.map((middleware, index) => (
<TooltipProvider key={`${middleware}-${index}`}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary">
<InfoIcon className="size-3 mr-1" />
Middleware: {middleware}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Traefik middleware reference</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -36,19 +36,16 @@ interface Props {
}
export const ShowEnvironment = ({ id, type }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canWrite = permissions?.envVars.write ?? false;
const queryMap = {
compose: () =>
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
compose: () =>
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
@@ -56,17 +53,16 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const [isEnvVisible, setIsEnvVisible] = useState(true);
const mutationMap = {
compose: () => api.compose.saveEnvironment.useMutation(),
libsql: () => api.libsql.saveEnvironment.useMutation(),
mariadb: () => api.mariadb.saveEnvironment.useMutation(),
mongo: () => api.mongo.saveEnvironment.useMutation(),
mysql: () => api.mysql.saveEnvironment.useMutation(),
postgres: () => api.postgres.saveEnvironment.useMutation(),
redis: () => api.redis.saveEnvironment.useMutation(),
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
compose: () => api.compose.update.useMutation(),
};
const { mutateAsync, isPending } = mutationMap[type]
? mutationMap[type]()
: api.mongo.saveEnvironment.useMutation();
: api.mongo.update.useMutation();
const form = useForm<EnvironmentSchema>({
defaultValues: {
@@ -89,13 +85,12 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const onSubmit = async (formData: EnvironmentSchema) => {
mutateAsync({
composeId: id || "",
libsqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
mysqlId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
composeId: id || "",
env: formData.environment,
})
.then(async () => {
@@ -116,7 +111,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
@@ -190,27 +185,25 @@ PORT=3000
)}
/>
{canWrite && (
<div className="flex flex-row justify-end gap-2">
{hasChanges && (
<Button
type="button"
variant="outline"
onClick={handleCancel}
>
Cancel
</Button>
)}
<div className="flex flex-row justify-end gap-2">
{hasChanges && (
<Button
isLoading={isPending}
className="w-fit"
type="submit"
disabled={!hasChanges}
type="button"
variant="outline"
onClick={handleCancel}
>
Save
Cancel
</Button>
</div>
)}
)}
<Button
isLoading={isPending}
className="w-fit"
type="submit"
disabled={!hasChanges}
>
Save
</Button>
</div>
</form>
</Form>
</CardContent>
@@ -31,8 +31,6 @@ interface Props {
}
export const ShowEnvironment = ({ applicationId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canWrite = permissions?.envVars.write ?? false;
const { mutateAsync, isPending } =
api.application.saveEnvironment.useMutation();
@@ -106,7 +104,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
@@ -203,30 +201,27 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!canWrite}
/>
</FormControl>
</FormItem>
)}
/>
)}
{canWrite && (
<div className="flex flex-row justify-end gap-2">
{hasChanges && (
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
)}
<Button
isLoading={isPending}
className="w-fit"
type="submit"
disabled={!hasChanges}
>
Save
<div className="flex flex-row justify-end gap-2">
{hasChanges && (
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
</div>
)}
)}
<Button
isLoading={isPending}
className="w-fit"
type="submit"
disabled={!hasChanges}
>
Save
</Button>
</div>
</form>
</Form>
</Card>
@@ -1,6 +1,5 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -58,10 +57,7 @@ const BitbucketProviderSchema = z.object({
slug: z.string().optional(),
})
.required(),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch is required"),
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().optional(),
@@ -420,8 +416,10 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent>
<p>
@@ -1,6 +1,5 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect } from "react";
@@ -42,10 +41,7 @@ const GitProviderSchema = z.object({
repositoryURL: z.string().min(1, {
message: "Repository URL is required",
}),
branch: z
.string()
.min(1, "Branch required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch required"),
sshKey: z.string().optional(),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
@@ -59,7 +55,7 @@ interface Props {
export const SaveGitProvider = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery({ applicationId });
const { data: sshKeys } = api.sshKey.allForApps.useQuery();
const { data: sshKeys } = api.sshKey.all.useQuery();
const router = useRouter();
const { mutateAsync, isPending } =
@@ -111,103 +107,110 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 items-start">
<FormField
control={form.control}
name="repositoryURL"
render={({ field }) => (
<FormItem className="col-span-2 lg:col-span-3">
<div className="flex items-center justify-between h-5">
<FormLabel>Repository URL</FormLabel>
{field.value?.startsWith("https://") && (
<Link
href={field.value}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<GitIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<FormControl>
<Input placeholder="Repository URL" {...field} />
</FormControl>
<FormMessage />
</FormItem>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="grid md:grid-cols-2 gap-4">
<div className="flex items-end col-span-2 gap-4">
<div className="grow">
<FormField
control={form.control}
name="repositoryURL"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Repository URL</FormLabel>
{field.value?.startsWith("https://") && (
<Link
href={field.value}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<GitIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<FormControl>
<Input placeholder="Repository URL" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{sshKeys && sshKeys.length > 0 ? (
<FormField
control={form.control}
name="sshKey"
render={({ field }) => (
<FormItem className="basis-40">
<FormLabel className="w-full inline-flex justify-between">
SSH Key
<LockIcon className="size-4 text-muted-foreground" />
</FormLabel>
<FormControl>
<Select
key={field.value}
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a key" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{sshKeys?.map((sshKey) => (
<SelectItem
key={sshKey.sshKeyId}
value={sshKey.sshKeyId}
>
{sshKey.name}
</SelectItem>
))}
<SelectItem value="none">None</SelectItem>
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
) : (
<Button
variant="secondary"
onClick={() => router.push("/dashboard/settings/ssh-keys")}
type="button"
>
<KeyRoundIcon className="size-4" /> Add SSH Key
</Button>
)}
/>
{sshKeys && sshKeys.length > 0 ? (
</div>
<div className="space-y-4">
<FormField
control={form.control}
name="sshKey"
name="branch"
render={({ field }) => (
<FormItem className="col-span-2 lg:col-span-1">
<FormLabel className="w-full inline-flex justify-between">
SSH Key
<LockIcon className="size-4 text-muted-foreground" />
</FormLabel>
<FormItem>
<FormLabel>Branch</FormLabel>
<FormControl>
<Select
key={field.value}
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a key" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{sshKeys?.map((sshKey) => (
<SelectItem
key={sshKey.sshKeyId}
value={sshKey.sshKeyId}
>
{sshKey.name}
</SelectItem>
))}
<SelectItem value="none">None</SelectItem>
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<Input placeholder="Branch" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
) : (
<Button
variant="secondary"
onClick={() => router.push("/dashboard/settings/ssh-keys")}
type="button"
className="col-span-2 lg:col-span-1 lg:mt-7"
>
<KeyRoundIcon className="size-4" /> Add SSH Key
</Button>
)}
<FormField
control={form.control}
name="branch"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Branch</FormLabel>
<FormControl>
<Input placeholder="Branch" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="buildPath"
render={({ field }) => (
<FormItem className="col-span-2">
<FormItem>
<FormLabel>Build Path</FormLabel>
<FormControl>
<Input placeholder="/" {...field} />
@@ -220,13 +223,15 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="col-span-2 lg:col-span-4">
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p>
@@ -1,4 +1,3 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
@@ -73,10 +72,7 @@ const GiteaProviderSchema = z.object({
owner: z.string().min(1, "Owner is required"),
})
.required(),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch is required"),
giteaId: z.string().min(1, "Gitea Provider is required"),
watchPaths: z.array(z.string()).default([]),
enableSubmodules: z.boolean().optional(),
@@ -1,4 +1,3 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
@@ -56,10 +55,7 @@ const GithubProviderSchema = z.object({
owner: z.string().min(1, "Owner is required"),
})
.required(),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch is required"),
githubId: z.string().min(1, "Github Provider is required"),
watchPaths: z.array(z.string()).optional(),
triggerType: z.enum(["push", "tag"]).default("push"),
@@ -1,4 +1,3 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
@@ -59,10 +58,7 @@ const GitlabProviderSchema = z.object({
id: z.number().nullable(),
})
.required(),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch is required"),
gitlabId: z.string().min(1, "Gitlab Provider is required"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
@@ -30,9 +30,6 @@ interface Props {
export const ShowGeneralApplication = ({ applicationId }: Props) => {
const router = useRouter();
const { data: permissions } = api.user.getPermissions.useQuery();
const canDeploy = permissions?.deployment.create ?? false;
const canUpdateService = permissions?.service.create ?? false;
const { data, refetch } = api.application.one.useQuery(
{
applicationId,
@@ -58,137 +55,130 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
<CardHeader>
<CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 lg:flex lg:flex-row lg:flex-wrap gap-4">
<CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
{canDeploy && (
<DialogAction
title="Deploy Application"
description="Are you sure you want to deploy this application?"
type="default"
onClick={async () => {
await deploy({
applicationId: applicationId,
<DialogAction
title="Deploy Application"
description="Are you sure you want to deploy this application?"
type="default"
onClick={async () => {
await deploy({
applicationId: applicationId,
})
.then(() => {
toast.success("Application deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
);
})
.then(() => {
toast.success("Application deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying application");
});
}}
.catch(() => {
toast.error("Error deploying application");
});
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Downloads the source code and performs a complete
build
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy && (
<DialogAction
title="Reload Application"
description="Are you sure you want to reload this application?"
type="default"
onClick={async () => {
await reload({
applicationId: applicationId,
appName: data?.appName || "",
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Downloads the source code and performs a complete build
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<DialogAction
title="Reload Application"
description="Are you sure you want to reload this application?"
type="default"
onClick={async () => {
await reload({
applicationId: applicationId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Application reloaded successfully");
refetch();
})
.then(() => {
toast.success("Application reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading application");
});
}}
.catch(() => {
toast.error("Error reloading application");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Reload the application without rebuilding it</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy && (
<DialogAction
title="Rebuild Application"
description="Are you sure you want to rebuild this application?"
type="default"
onClick={async () => {
await redeploy({
applicationId: applicationId,
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Reload the application without rebuilding it</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<DialogAction
title="Rebuild Application"
description="Are you sure you want to rebuild this application?"
type="default"
onClick={async () => {
await redeploy({
applicationId: applicationId,
})
.then(() => {
toast.success("Application rebuilt successfully");
refetch();
})
.then(() => {
toast.success("Application rebuilt successfully");
refetch();
})
.catch(() => {
toast.error("Error rebuilding application");
});
}}
.catch(() => {
toast.error("Error rebuilding application");
});
}}
>
<Button
variant="secondary"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Button
variant="secondary"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Hammer className="size-4 mr-1" />
Rebuild
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Only rebuilds the application without downloading new
code
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Hammer className="size-4 mr-1" />
Rebuild
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Only rebuilds the application without downloading new
code
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
{canDeploy && data?.applicationStatus === "idle" ? (
{data?.applicationStatus === "idle" ? (
<DialogAction
title="Start Application"
description="Are you sure you want to start this application?"
@@ -229,7 +219,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
</Tooltip>
</Button>
</DialogAction>
) : canDeploy ? (
) : (
<DialogAction
title="Stop Application"
description="Are you sure you want to stop this application?"
@@ -266,7 +256,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
</Tooltip>
</Button>
</DialogAction>
) : null}
)}
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
@@ -274,59 +264,55 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
>
<Button
variant="outline"
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2 col-span-2"
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Terminal className="size-4 mr-1" />
Open Terminal
</Button>
</DockerTerminalModal>
{canUpdateService && (
<div className="flex flex-row items-center gap-2 justify-between rounded-md px-4 py-2 border col-span-2 md:col-span-1">
<span className="text-sm font-medium">Autodeploy</span>
<Switch
aria-label="Toggle autodeploy"
checked={data?.autoDeploy || false}
onCheckedChange={async (enabled) => {
await update({
applicationId,
autoDeploy: enabled,
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Autodeploy</span>
<Switch
aria-label="Toggle autodeploy"
checked={data?.autoDeploy || false}
onCheckedChange={async (enabled) => {
await update({
applicationId,
autoDeploy: enabled,
})
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
})
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
})
.catch(() => {
toast.error("Error updating Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
</div>
)}
.catch(() => {
toast.error("Error updating Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
</div>
{canUpdateService && (
<div className="flex flex-row items-center gap-2 justify-between rounded-md px-4 py-2 border col-span-2 md:col-span-1">
<span className="text-sm font-medium">Clean Cache</span>
<Switch
aria-label="Toggle clean cache"
checked={data?.cleanCache || false}
onCheckedChange={async (enabled) => {
await update({
applicationId,
cleanCache: enabled,
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Clean Cache</span>
<Switch
aria-label="Toggle clean cache"
checked={data?.cleanCache || false}
onCheckedChange={async (enabled) => {
await update({
applicationId,
cleanCache: enabled,
})
.then(async () => {
toast.success("Clean Cache Updated");
await refetch();
})
.then(async () => {
toast.success("Clean Cache Updated");
await refetch();
})
.catch(() => {
toast.error("Error updating Clean Cache");
});
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
</div>
)}
.catch(() => {
toast.error("Error updating Clean Cache");
});
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
</div>
</CardContent>
</Card>
<ShowProviderForm applicationId={applicationId} />
@@ -1,277 +0,0 @@
import DOMPurify from "dompurify";
import { GlobeIcon, Pencil, Search, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Dropzone } from "@/components/ui/dropzone";
import { Input } from "@/components/ui/input";
import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
import { api } from "@/utils/api";
interface ShowIconSettingsProps {
applicationId: string;
icon?: string | null;
}
const svgToDataUrl = (icon: BundledIcon): string => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#${icon.hex}"><path d="${icon.path}"/></svg>`;
return `data:image/svg+xml;base64,${btoa(svg)}`;
};
export const ShowIconSettings = ({
applicationId,
icon,
}: ShowIconSettingsProps) => {
const [open, setOpen] = useState(false);
const [iconSearchQuery, setIconSearchQuery] = useState("");
const [iconsToShow, setIconsToShow] = useState(24);
const filteredIcons = useMemo(() => {
if (!iconSearchQuery) return bundledIcons;
const q = iconSearchQuery.toLowerCase();
return bundledIcons.filter(
(i) =>
i.title.toLowerCase().includes(q) || i.slug.toLowerCase().includes(q),
);
}, [iconSearchQuery]);
const displayedIcons = filteredIcons.slice(0, iconsToShow);
const hasMoreIcons = filteredIcons.length > iconsToShow;
const utils = api.useUtils();
const { mutateAsync: updateApplication } =
api.application.update.useMutation();
useEffect(() => {
if (open) {
setIconSearchQuery("");
setIconsToShow(24);
}
}, [open]);
const handleIconSelect = async (selectedIcon: BundledIcon) => {
try {
const dataUrl = svgToDataUrl(selectedIcon);
await updateApplication({
applicationId,
icon: dataUrl,
});
toast.success("Icon saved successfully");
await utils.application.one.invalidate({ applicationId });
setOpen(false);
} catch (_error) {
toast.error("Error saving icon");
}
};
const handleRemoveIcon = async () => {
try {
await updateApplication({
applicationId,
icon: null,
});
toast.success("Icon removed");
await utils.application.one.invalidate({ applicationId });
} catch (_error) {
toast.error("Error removing icon");
}
};
const sanitizeSvg = (svgContent: string): string | null => {
const clean = DOMPurify.sanitize(svgContent, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ["use"],
});
if (!clean) return null;
return `data:image/svg+xml;base64,${btoa(clean)}`;
};
const handleFileUpload = async (files: FileList | null) => {
if (!files || files.length === 0) return;
const file = files[0];
if (!file) return;
const allowedTypes = [
"image/jpeg",
"image/jpg",
"image/png",
"image/svg+xml",
];
const fileExtension = file.name.split(".").pop()?.toLowerCase();
const allowedExtensions = ["jpg", "jpeg", "png", "svg"];
if (
!allowedTypes.includes(file.type) &&
!allowedExtensions.includes(fileExtension || "")
) {
toast.error("Only JPG, JPEG, PNG, and SVG files are allowed");
return;
}
if (file.size > 2 * 1024 * 1024) {
toast.error("Image size must be less than 2MB");
return;
}
const isSvg = file.type === "image/svg+xml" || fileExtension === "svg";
if (isSvg) {
const text = await file.text();
const sanitizedDataUrl = sanitizeSvg(text);
if (!sanitizedDataUrl) {
toast.error("Invalid SVG file");
return;
}
try {
await updateApplication({
applicationId,
icon: sanitizedDataUrl,
});
toast.success("Icon saved!");
await utils.application.one.invalidate({ applicationId });
setOpen(false);
} catch (_error) {
toast.error("Error saving icon");
}
return;
}
const reader = new FileReader();
reader.onload = async (event) => {
const result = event.target?.result as string;
try {
await updateApplication({
applicationId,
icon: result,
});
toast.success("Icon saved!");
await utils.application.one.invalidate({ applicationId });
setOpen(false);
} catch (_error) {
toast.error("Error saving icon");
}
};
reader.readAsDataURL(file);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<button
type="button"
className="relative group flex items-center justify-center"
>
{icon ? (
// biome-ignore lint/performance/noImgElement: icon is data URL or base64
<img
src={icon}
alt="Application icon"
className="h-8 w-8 object-contain"
/>
) : (
<GlobeIcon className="h-6 w-6 text-muted-foreground" />
)}
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded opacity-0 group-hover:opacity-100 transition-opacity">
<Pencil className="h-3 w-3 text-white" />
</div>
</button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
Change Icon
{icon && (
<Button
variant="ghost"
size="sm"
onClick={handleRemoveIcon}
className="text-muted-foreground"
>
<X className="size-4 mr-1" />
Remove icon
</Button>
)}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search icons (e.g. react, vue, docker)..."
value={iconSearchQuery}
onChange={(e) => setIconSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<div className="max-h-[300px] overflow-y-auto border rounded-lg p-4">
{displayedIcons.length === 0 ? (
<div className="text-center py-8 text-sm text-muted-foreground">
No icons found
</div>
) : (
<>
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
{displayedIcons.map((i) => (
<button
type="button"
key={i.slug}
onClick={() => handleIconSelect(i)}
className="flex flex-col items-center gap-1.5 p-2 rounded-lg border hover:border-primary hover:bg-muted transition-colors group"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
className="size-7 group-hover:scale-110 transition-transform"
fill={`#${i.hex}`}
>
<path d={i.path} />
</svg>
<span className="text-[10px] text-muted-foreground capitalize truncate w-full text-center">
{i.title}
</span>
</button>
))}
</div>
{hasMoreIcons && (
<div className="flex justify-center mt-3">
<Button
variant="outline"
size="sm"
onClick={() => setIconsToShow((prev) => prev + 24)}
>
Load More ({filteredIcons.length - iconsToShow} remaining)
</Button>
</div>
)}
</>
)}
</div>
<div className="relative pt-3 border-t">
<p className="text-sm text-muted-foreground text-center mb-3">
or upload a custom icon
</p>
<Dropzone
dropMessage="Drag & drop an icon or click to upload"
accept=".jpg,.jpeg,.png,.svg,image/jpeg,image/png,image/svg+xml"
onChange={handleFileUpload}
classNameWrapper="border-2 border-dashed border-border hover:border-primary bg-muted/30 hover:bg-muted/50 transition-all rounded-lg"
/>
<div className="mt-2 text-center text-xs text-muted-foreground">
Supported formats: JPG, JPEG, PNG, SVG (max 2MB)
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -91,7 +91,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
}, [option, services, containers]);
const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLength =
const containersLenght =
option === "native" ? containers?.length : services?.length;
return (
@@ -167,7 +167,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
</>
)}
<SelectLabel>Containers ({containersLength})</SelectLabel>
<SelectLabel>Containers ({containersLenght})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
@@ -1,2 +1,2 @@
export * from "./patch-editor";
export * from "./show-patches";
export * from "./patch-editor";
@@ -87,7 +87,7 @@ export const AddPreviewDomain = ({
});
const host = form.watch("host");
const isTraefikMeDomain = host?.includes("sslip.io") || false;
const isTraefikMeDomain = host?.includes("traefik.me") || false;
useEffect(() => {
if (data) {
@@ -162,7 +162,7 @@ export const AddPreviewDomain = ({
<FormItem>
{isTraefikMeDomain && (
<AlertBlock type="info">
<strong>Note:</strong> sslip.io is a public HTTP
<strong>Note:</strong> traefik.me is a public HTTP
service and does not support SSL/HTTPS. HTTPS and
certificate options will not have any effect.
</AlertBlock>
@@ -202,7 +202,7 @@ export const AddPreviewDomain = ({
sideOffset={5}
className="max-w-[10rem]"
>
<p>Generate sslip.io domain</p>
<p>Generate traefik.me domain</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -88,7 +88,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
const form = useForm<Schema>({
defaultValues: {
env: "",
wildcardDomain: "*.sslip.io",
wildcardDomain: "*.traefik.me",
port: 3000,
previewLimit: 3,
previewLabels: [],
@@ -102,7 +102,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
const previewHttps = form.watch("previewHttps");
const wildcardDomain = form.watch("wildcardDomain");
const isTraefikMeDomain = wildcardDomain?.includes("sslip.io") || false;
const isTraefikMeDomain = wildcardDomain?.includes("traefik.me") || false;
useEffect(() => {
setIsEnabled(data?.isPreviewDeploymentsActive || false);
@@ -114,7 +114,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
env: data.previewEnv || "",
buildArgs: data.previewBuildArgs || "",
buildSecrets: data.previewBuildSecrets || "",
wildcardDomain: data.previewWildcard || "*.sslip.io",
wildcardDomain: data.previewWildcard || "*.traefik.me",
port: data.previewPort || 3000,
previewLabels: data.previewLabels || [],
previewLimit: data.previewLimit || 3,
@@ -173,7 +173,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
<div className="grid gap-4">
{isTraefikMeDomain && (
<AlertBlock type="info">
<strong>Note:</strong> sslip.io is a public HTTP service and
<strong>Note:</strong> traefik.me is a public HTTP service and
does not support SSL/HTTPS. HTTPS and certificate options will
not have any effect.
</AlertBlock>
@@ -192,7 +192,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
<FormItem>
<FormLabel>Wildcard Domain</FormLabel>
<FormControl>
<Input placeholder="*.sslip.io" {...field} />
<Input placeholder="*.traefik.me" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -80,7 +80,6 @@ export const commonCronExpressions = [
const formSchema = z
.object({
name: z.string().min(1, "Name is required"),
description: z.string().optional(),
cronExpression: z.string().min(1, "Cron expression is required"),
shellType: z.enum(["bash", "sh"]).default("bash"),
command: z.string(),
@@ -225,7 +224,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
resolver: standardSchemaResolver(formSchema),
defaultValues: {
name: "",
description: "",
cronExpression: "",
shellType: "bash",
command: "",
@@ -265,7 +263,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
if (scheduleId && schedule) {
form.reset({
name: schedule.name,
description: schedule.description || "",
cronExpression: schedule.cronExpression,
shellType: schedule.shellType,
command: schedule.command,
@@ -482,26 +479,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
placeholder="Backs up the database every day at midnight"
{...field}
/>
</FormControl>
<FormDescription>
Optional description of what this schedule does
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<ScheduleFormField
name="cronExpression"
formControl={form.control}
@@ -125,11 +125,6 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
{schedule.enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
{schedule.description && (
<p className="text-xs text-muted-foreground/70 [overflow-wrap:anywhere] line-clamp-2">
{schedule.description}
</p>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
<Badge
variant="outline"
@@ -71,7 +71,6 @@ const formSchema = z
"mongo",
"mysql",
"redis",
"libsql",
]),
serviceName: z.string(),
destinationId: z.string().min(1, "Destination required"),
@@ -483,7 +482,7 @@ export const HandleVolumeBackups = ({
</SelectContent>
</Select>
<FormDescription>
Choose the volume to backup. If you do not see the
Choose the volume to backup, if you dont see the
volume here, you can type the volume name manually
</FormDescription>
<FormMessage />
@@ -518,7 +517,7 @@ export const HandleVolumeBackups = ({
</SelectContent>
</Select>
<FormDescription>
Choose the volume to backup. If you do not see the volume
Choose the volume to backup, if you dont see the volume
here, you can type the volume name manually
</FormDescription>
<FormMessage />
@@ -1,290 +0,0 @@
import { Loader2, MoreHorizontal, RefreshCw } from "lucide-react";
import dynamic from "next/dynamic";
import { useState } from "react";
import { toast } from "sonner";
import { ShowContainerConfig } from "@/components/dashboard/docker/config/show-container-config";
import { ShowContainerMounts } from "@/components/dashboard/docker/mounts/show-container-mounts";
import { ShowContainerNetworks } from "@/components/dashboard/docker/networks/show-container-networks";
import { DockerTerminalModal } from "@/components/dashboard/docker/terminal/docker-terminal-modal";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
const DockerLogsId = dynamic(
() =>
import("@/components/dashboard/docker/logs/docker-logs-id").then(
(e) => e.DockerLogsId,
),
{
ssr: false,
},
);
interface Props {
appName: string;
serverId?: string;
appType: "stack" | "docker-compose";
}
export const ShowComposeContainers = ({
appName,
appType,
serverId,
}: Props) => {
const { data, isPending, refetch } =
api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
appType,
serverId,
},
{
enabled: !!appName,
},
);
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-xl">Containers</CardTitle>
<CardDescription>
Inspect each container in this compose and run basic lifecycle
actions.
</CardDescription>
</div>
<Button
variant="outline"
size="icon"
onClick={() => refetch()}
disabled={isPending}
>
<RefreshCw className={`h-4 w-4 ${isPending ? "animate-spin" : ""}`} />
</Button>
</CardHeader>
<CardContent>
{isPending ? (
<div className="flex items-center justify-center h-[20vh]">
<Loader2 className="animate-spin h-6 w-6 text-muted-foreground" />
</div>
) : !data || data.length === 0 ? (
<div className="flex items-center justify-center h-[20vh]">
<span className="text-muted-foreground">
No containers found. Deploy the compose to see containers here.
</span>
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>State</TableHead>
<TableHead>Status</TableHead>
<TableHead>Container ID</TableHead>
<TableHead className="text-right" />
</TableRow>
</TableHeader>
<TableBody>
{data.map((container) => (
<ContainerRow
key={container.containerId}
container={container}
serverId={serverId}
onActionComplete={() => refetch()}
/>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
);
};
interface ContainerRowProps {
container: {
containerId: string;
name: string;
state: string;
status: string;
};
serverId?: string;
onActionComplete: () => void;
}
const ContainerRow = ({
container,
serverId,
onActionComplete,
}: ContainerRowProps) => {
const [logsOpen, setLogsOpen] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const restartMutation = api.docker.restartContainer.useMutation();
const startMutation = api.docker.startContainer.useMutation();
const stopMutation = api.docker.stopContainer.useMutation();
const killMutation = api.docker.killContainer.useMutation();
const handleAction = async (
action: string,
mutationFn: typeof restartMutation,
) => {
setActionLoading(action);
try {
await mutationFn.mutateAsync({
containerId: container.containerId,
serverId,
});
toast.success(`Container ${action} successfully`);
onActionComplete();
} catch (error) {
toast.error(
`Failed to ${action} container: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
setActionLoading(null);
}
};
return (
<TableRow>
<TableCell className="font-medium">{container.name}</TableCell>
<TableCell>
<Badge
variant={
container.state === "running"
? "default"
: container.state === "exited"
? "secondary"
: "destructive"
}
>
{container.state}
</Badge>
</TableCell>
<TableCell>{container.status}</TableCell>
<TableCell className="font-mono text-sm text-muted-foreground">
{container.containerId}
</TableCell>
<TableCell className="text-right">
<Dialog open={logsOpen} onOpenChange={setLogsOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
{actionLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DialogTrigger asChild>
<DropdownMenuItem
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
View Logs
</DropdownMenuItem>
</DialogTrigger>
<ShowContainerConfig
containerId={container.containerId}
serverId={serverId || ""}
/>
<ShowContainerMounts
containerId={container.containerId}
serverId={serverId || ""}
/>
<ShowContainerNetworks
containerId={container.containerId}
serverId={serverId || ""}
/>
<DockerTerminalModal
containerId={container.containerId}
serverId={serverId || ""}
>
Terminal
</DockerTerminalModal>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("restart", restartMutation)}
>
Restart
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("start", startMutation)}
>
Start
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("stop", stopMutation)}
>
Stop
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer text-red-500 focus:text-red-600"
disabled={actionLoading !== null}
onClick={() => handleAction("kill", killMutation)}
>
Kill
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent className="sm:max-w-7xl">
<DialogHeader>
<DialogTitle>View Logs</DialogTitle>
<DialogDescription>Logs for {container.name}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2.5">
<DockerLogsId
containerId={container.containerId}
serverId={serverId}
runType="native"
/>
</div>
</DialogContent>
</Dialog>
</TableCell>
</TableRow>
);
};
@@ -46,8 +46,6 @@ interface Props {
}
export const DeleteService = ({ id, type }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canDelete = permissions?.service.delete ?? false;
const [isOpen, setIsOpen] = useState(false);
const queryMap = {
@@ -57,7 +55,6 @@ export const DeleteService = ({ id, type }: Props) => {
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
@@ -73,7 +70,6 @@ export const DeleteService = ({ id, type }: Props) => {
redis: () => api.redis.remove.useMutation(),
mysql: () => api.mysql.remove.useMutation(),
mariadb: () => api.mariadb.remove.useMutation(),
libsql: () => api.libsql.remove.useMutation(),
application: () => api.application.delete.useMutation(),
mongo: () => api.mongo.remove.useMutation(),
compose: () => api.compose.delete.useMutation(),
@@ -100,7 +96,6 @@ export const DeleteService = ({ id, type }: Props) => {
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
libsqlId: id || "",
applicationId: id || "",
composeId: id || "",
deleteVolumes,
@@ -128,8 +123,6 @@ export const DeleteService = ({ id, type }: Props) => {
data?.applicationStatus === "running") ||
(data && "composeStatus" in data && data?.composeStatus === "running");
if (!canDelete) return null;
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
@@ -19,9 +19,6 @@ interface Props {
}
export const ComposeActions = ({ composeId }: Props) => {
const router = useRouter();
const { data: permissions } = api.user.getPermissions.useQuery();
const canDeploy = permissions?.deployment.create ?? false;
const canUpdateService = permissions?.service.create ?? false;
const { data, refetch } = api.compose.one.useQuery(
{
composeId,
@@ -38,169 +35,162 @@ export const ComposeActions = ({ composeId }: Props) => {
return (
<div className="flex flex-row gap-4 w-full flex-wrap ">
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
{canDeploy && (
<DialogAction
title="Deploy Compose"
description="Are you sure you want to deploy this compose?"
type="default"
onClick={async () => {
await deploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying compose");
});
}}
>
<Button
variant="default"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads the source code and performs a complete build</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<DialogAction
title="Reload Compose"
description="Are you sure you want to reload this compose?"
type="default"
onClick={async () => {
await redeploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading compose");
});
}}
>
<Button
variant="secondary"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Reload the compose without rebuilding it</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
{data?.composeType === "docker-compose" &&
data?.composeStatus === "idle" ? (
<DialogAction
title="Deploy Compose"
description="Are you sure you want to deploy this compose?"
title="Start Compose"
description="Are you sure you want to start this compose?"
type="default"
onClick={async () => {
await deploy({
await start({
composeId: composeId,
})
.then(() => {
toast.success("Compose deployed successfully");
toast.success("Compose started successfully");
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying compose");
toast.error("Error starting compose");
});
}}
>
<Button
variant="default"
isLoading={data?.composeStatus === "running"}
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">
<Rocket className="size-4 mr-1" />
Deploy
<CheckCircle2 className="size-4 mr-1" />
Start
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Downloads the source code and performs a complete build
Start the compose (requires a previous successful build)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy && (
) : (
<DialogAction
title="Reload Compose"
description="Are you sure you want to reload this compose?"
type="default"
title="Stop Compose"
description="Are you sure you want to stop this compose?"
onClick={async () => {
await redeploy({
await stop({
composeId: composeId,
})
.then(() => {
toast.success("Compose reloaded successfully");
toast.success("Compose stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading compose");
toast.error("Error stopping compose");
});
}}
>
<Button
variant="secondary"
isLoading={data?.composeStatus === "running"}
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">
<RefreshCcw className="size-4 mr-1" />
Reload
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Reload the compose without rebuilding it</p>
<p>Stop the currently running compose</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy &&
(data?.composeType === "docker-compose" &&
data?.composeStatus === "idle" ? (
<DialogAction
title="Start Compose"
description="Are you sure you want to start this compose?"
type="default"
onClick={async () => {
await start({
composeId: composeId,
})
.then(() => {
toast.success("Compose started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting compose");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the compose (requires a previous successful build)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Compose"
description="Are you sure you want to stop this compose?"
onClick={async () => {
await stop({
composeId: composeId,
})
.then(() => {
toast.success("Compose stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping compose");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running compose</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
))}
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
@@ -215,29 +205,27 @@ export const ComposeActions = ({ composeId }: Props) => {
Open Terminal
</Button>
</DockerTerminalModal>
{canUpdateService && (
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Autodeploy</span>
<Switch
aria-label="Toggle autodeploy"
checked={data?.autoDeploy || false}
onCheckedChange={async (enabled) => {
await update({
composeId,
autoDeploy: enabled,
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Autodeploy</span>
<Switch
aria-label="Toggle autodeploy"
checked={data?.autoDeploy || false}
onCheckedChange={async (enabled) => {
await update({
composeId,
autoDeploy: enabled,
})
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
})
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
})
.catch(() => {
toast.error("Error updating Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
</div>
)}
.catch(() => {
toast.error("Error updating Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
</div>
</div>
);
};
@@ -26,8 +26,6 @@ const AddComposeFile = z.object({
type AddComposeFile = z.infer<typeof AddComposeFile>;
export const ComposeFileEditor = ({ composeId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canUpdate = permissions?.service.create ?? false;
const utils = api.useUtils();
const { data, refetch } = api.compose.one.useQuery(
{
@@ -49,12 +47,12 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
const composeFile = form.watch("composeFile");
useEffect(() => {
if (data) {
if (data && !composeFile) {
form.reset({
composeFile: data.composeFile || "",
});
}
}, [form, data]);
}, [form, form.reset, data]);
useEffect(() => {
if (data?.composeFile !== undefined) {
@@ -95,7 +93,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
@@ -166,16 +164,14 @@ services:
</Form>
<div className="flex justify-between flex-col lg:flex-row gap-2">
<div className="w-full flex flex-col lg:flex-row gap-4 items-end" />
{canUpdate && (
<Button
type="submit"
form="hook-form-save-compose-file"
isLoading={isPending}
className="lg:w-fit w-full"
>
Save
</Button>
)}
<Button
type="submit"
form="hook-form-save-compose-file"
isLoading={isPending}
className="lg:w-fit w-full"
>
Save
</Button>
</div>
</div>
</>
@@ -1,4 +1,3 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
@@ -58,10 +57,7 @@ const BitbucketProviderSchema = z.object({
slug: z.string().optional(),
})
.required(),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch is required"),
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
@@ -422,7 +418,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger type="button">
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
@@ -1,6 +1,5 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect } from "react";
@@ -42,10 +41,7 @@ const GitProviderSchema = z.object({
repositoryURL: z.string().min(1, {
message: "Repository URL is required",
}),
branch: z
.string()
.min(1, "Branch required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch required"),
sshKey: z.string().optional(),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
@@ -59,7 +55,7 @@ interface Props {
export const SaveGitProviderCompose = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery({ composeId });
const { data: sshKeys } = api.sshKey.allForApps.useQuery();
const { data: sshKeys } = api.sshKey.all.useQuery();
const router = useRouter();
const { mutateAsync, isPending } = api.compose.update.useMutation();
@@ -234,8 +230,10 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p>
@@ -1,6 +1,5 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import { CheckIcon, ChevronsUpDown, Plus, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -58,10 +57,7 @@ const GiteaProviderSchema = z.object({
owner: z.string().min(1, "Owner is required"),
})
.required(),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch is required"),
giteaId: z.string().min(1, "Gitea Provider is required"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
@@ -413,8 +409,10 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent>
<p>
@@ -1,4 +1,3 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
import Link from "next/link";
@@ -56,10 +55,7 @@ const GithubProviderSchema = z.object({
owner: z.string().min(1, "Owner is required"),
})
.required(),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch is required"),
githubId: z.string().min(1, "Github Provider is required"),
watchPaths: z.array(z.string()).optional(),
triggerType: z.enum(["push", "tag"]).default("push"),
@@ -449,7 +445,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger type="button">
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
@@ -1,4 +1,3 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
@@ -59,10 +58,7 @@ const GitlabProviderSchema = z.object({
gitlabPathNamespace: z.string().min(1),
})
.required(),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch is required"),
gitlabId: z.string().min(1, "Gitlab Provider is required"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
@@ -440,7 +436,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger type="button">
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
@@ -77,7 +77,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
}, [option, services, containers]);
const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLength =
const containersLenght =
option === "native" ? containers?.length : services?.length;
return (
@@ -152,7 +152,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
</>
)}
<SelectLabel>Containers ({containersLength})</SelectLabel>
<SelectLabel>Containers ({containersLenght})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
@@ -65,13 +65,7 @@ import { ScheduleFormField } from "../../application/schedules/handle-schedules"
type CacheType = "cache" | "fetch";
type DatabaseType =
| "postgres"
| "mariadb"
| "mysql"
| "mongo"
| "web-server"
| "libsql";
type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
const Schema = z
.object({
@@ -83,7 +77,7 @@ const Schema = z
keepLatestCount: z.coerce.number().optional(),
serviceName: z.string().nullable(),
databaseType: z
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server", "libsql"])
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
.optional(),
backupType: z.enum(["database", "compose"]),
metadata: z
@@ -215,12 +209,7 @@ export const HandleBackup = ({
const form = useForm({
defaultValues: {
database:
databaseType === "web-server"
? "dokploy"
: databaseType === "libsql"
? "iku.db"
: "",
database: databaseType === "web-server" ? "dokploy" : "",
destinationId: "",
enabled: true,
prefix: "/",
@@ -257,9 +246,7 @@ export const HandleBackup = ({
? backup?.database
: databaseType === "web-server"
? "dokploy"
: databaseType === "libsql"
? "iku.db"
: "",
: "",
destinationId: backup?.destinationId ?? "",
enabled: backup?.enabled ?? true,
prefix: backup?.prefix ?? "/",
@@ -294,15 +281,11 @@ export const HandleBackup = ({
? {
mongoId: id,
}
: databaseType === "libsql"
: databaseType === "web-server"
? {
libsqlId: id,
userId: id,
}
: databaseType === "web-server"
? {
userId: id,
}
: undefined;
: undefined;
await createBackup({
destinationId: data.destinationId,
@@ -585,10 +568,7 @@ export const HandleBackup = ({
<FormLabel>Database</FormLabel>
<FormControl>
<Input
disabled={
databaseType === "web-server" ||
databaseType === "libsql"
}
disabled={databaseType === "web-server"}
placeholder={"dokploy"}
{...field}
/>
@@ -88,7 +88,7 @@ const RestoreBackupSchema = z
message: "Database name is required",
}),
databaseType: z
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server", "libsql"])
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
.optional(),
backupType: z.enum(["database", "compose"]).default("database"),
metadata: z
@@ -211,12 +211,7 @@ export const RestoreBackup = ({
defaultValues: {
destinationId: "",
backupFile: "",
databaseName:
databaseType === "web-server"
? "dokploy"
: databaseType === "libsql"
? "iku.db"
: "",
databaseName: databaseType === "web-server" ? "dokploy" : "",
databaseType:
backupType === "compose" ? ("postgres" as DatabaseType) : databaseType,
backupType: backupType,
@@ -225,7 +220,7 @@ export const RestoreBackup = ({
resolver: zodResolver(RestoreBackupSchema),
});
const destinationId = form.watch("destinationId");
const destionationId = form.watch("destinationId");
const currentDatabaseType = form.watch("databaseType");
const metadata = form.watch("metadata");
@@ -240,12 +235,12 @@ export const RestoreBackup = ({
const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery(
{
destinationId: destinationId,
destinationId: destionationId,
search: debouncedSearchTerm,
serverId: serverId ?? "",
},
{
enabled: isOpen && !!destinationId,
enabled: isOpen && !!destionationId,
},
);
@@ -288,6 +283,7 @@ export const RestoreBackup = ({
toast.error("Please select a database type");
return;
}
console.log({ data });
setIsDeploying(true);
};
@@ -527,10 +523,7 @@ export const RestoreBackup = ({
<Input
placeholder="Enter database name"
{...field}
disabled={
databaseType === "web-server" ||
databaseType === "libsql"
}
disabled={databaseType === "web-server"}
/>
</FormControl>
<FormMessage />
@@ -53,16 +53,14 @@ export const ShowBackups = ({
const queryMap =
backupType === "database"
? {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
mysql: () =>
api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () =>
api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
mysql: () =>
api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
libsql: () =>
api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
"web-server": () => api.user.getBackups.useQuery(),
}
: {
@@ -79,11 +77,10 @@ export const ShowBackups = ({
const mutationMap =
backupType === "database"
? {
postgres: api.backup.manualBackupPostgres.useMutation(),
mysql: api.backup.manualBackupMySql.useMutation(),
mariadb: api.backup.manualBackupMariadb.useMutation(),
mongo: api.backup.manualBackupMongo.useMutation(),
mysql: api.backup.manualBackupMySql.useMutation(),
postgres: api.backup.manualBackupPostgres.useMutation(),
libsql: api.backup.manualBackupLibsql.useMutation(),
"web-server": api.backup.manualBackupWebServer.useMutation(),
}
: {
@@ -1,613 +0,0 @@
"use client";
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import type { inferRouterOutputs } from "@trpc/server";
import {
ArrowUpDown,
Boxes,
ChevronLeft,
ChevronRight,
ExternalLink,
Loader2,
Rocket,
Server,
} from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AppRouter } from "@/server/api/root";
import { api } from "@/utils/api";
type DeploymentRow =
inferRouterOutputs<AppRouter>["deployment"]["allCentralized"][number];
const statusVariants: Record<
string,
| "default"
| "secondary"
| "destructive"
| "outline"
| "yellow"
| "green"
| "red"
> = {
running: "yellow",
done: "green",
error: "red",
cancelled: "outline",
};
function getServiceInfo(d: DeploymentRow) {
const app = d.application;
const comp = d.compose;
if (app?.environment?.project && app.environment) {
return {
type: "Application" as const,
name: app.name,
projectId: app.environment.project.projectId,
environmentId: app.environment.environmentId,
projectName: app.environment.project.name,
environmentName: app.environment.name,
serviceId: app.applicationId,
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
};
}
if (comp?.environment?.project && comp.environment) {
return {
type: "Compose" as const,
name: comp.name,
projectId: comp.environment.project.projectId,
environmentId: comp.environment.environmentId,
projectName: comp.environment.project.name,
environmentName: comp.environment.name,
serviceId: comp.composeId,
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
};
}
return null;
}
export function ShowDeploymentsTable() {
const [sorting, setSorting] = useState<SortingState>([
{ id: "createdAt", desc: true },
]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 50,
});
const { data: deploymentsList, isLoading } =
api.deployment.allCentralized.useQuery(undefined, {
refetchInterval: 5000,
});
const filteredData = useMemo(() => {
if (!deploymentsList) return [];
let list = deploymentsList;
if (statusFilter !== "all") {
list = list.filter((d) => d.status === statusFilter);
}
if (typeFilter === "application") {
list = list.filter((d) => d.applicationId != null);
} else if (typeFilter === "compose") {
list = list.filter((d) => d.composeId != null);
}
if (globalFilter.trim()) {
const q = globalFilter.toLowerCase();
list = list.filter((d) => {
const info = getServiceInfo(d);
const serverName =
d.server?.name ??
d.application?.server?.name ??
d.compose?.server?.name ??
"";
const buildServerName =
d.buildServer?.name ?? d.application?.buildServer?.name ?? "";
if (!info) return false;
return (
info.name.toLowerCase().includes(q) ||
info.projectName.toLowerCase().includes(q) ||
info.environmentName.toLowerCase().includes(q) ||
(d.title?.toLowerCase().includes(q) ?? false) ||
serverName.toLowerCase().includes(q) ||
buildServerName.toLowerCase().includes(q)
);
});
}
return list;
}, [deploymentsList, statusFilter, typeFilter, globalFilter]);
const columns = useMemo(
() => [
{
id: "serviceName",
accessorFn: (row: DeploymentRow) => getServiceInfo(row)?.name ?? "",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Service
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => {
const info = getServiceInfo(row.original);
if (!info) return <span className="text-muted-foreground"></span>;
return (
<div className="flex items-center gap-2">
{info.type === "Application" ? (
<Rocket className="size-4 text-muted-foreground shrink-0" />
) : (
<Boxes className="size-4 text-muted-foreground shrink-0" />
)}
<div className="flex flex-col min-w-0">
<span className="font-medium truncate">{info.name}</span>
<Badge variant="outline" className="w-fit text-[10px]">
{info.type}
</Badge>
</div>
</div>
);
},
},
{
id: "projectName",
accessorFn: (row: DeploymentRow) =>
getServiceInfo(row)?.projectName ?? "",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Project
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => {
const info = getServiceInfo(row.original);
return (
<span className="text-muted-foreground">
{info?.projectName ?? "—"}
</span>
);
},
},
{
id: "environmentName",
accessorFn: (row: DeploymentRow) =>
getServiceInfo(row)?.environmentName ?? "",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Environment
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => {
const info = getServiceInfo(row.original);
return (
<span className="text-muted-foreground">
{info?.environmentName ?? "—"}
</span>
);
},
},
{
id: "serverName",
accessorFn: (row: DeploymentRow) =>
row.server?.name ??
row.application?.server?.name ??
row.compose?.server?.name ??
"",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Server
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => {
const d = row.original;
const serverName =
d.server?.name ??
d.application?.server?.name ??
d.compose?.server?.name ??
null;
const serverType =
d.server?.serverType ??
d.application?.server?.serverType ??
d.compose?.server?.serverType ??
null;
const buildServerName =
d.buildServer?.name ?? d.application?.buildServer?.name ?? null;
const buildServerType =
d.buildServer?.serverType ??
d.application?.buildServer?.serverType ??
null;
const showBuild =
buildServerName != null && buildServerName !== serverName;
if (!serverName && !showBuild) {
return <span className="text-muted-foreground"></span>;
}
return (
<div className="flex flex-col gap-0.5 text-sm">
{serverName && (
<div className="flex items-center gap-1.5 flex-wrap">
<Server className="size-3.5 text-muted-foreground shrink-0" />
<span className="truncate">{serverName}</span>
{serverType && (
<Badge
variant="outline"
className="text-[10px] font-normal"
>
{serverType}
</Badge>
)}
</div>
)}
{showBuild && buildServerName && (
<div className="flex items-center gap-1.5 text-muted-foreground flex-wrap">
<span className="text-[10px]">Build:</span>
<span className="truncate text-xs">{buildServerName}</span>
{buildServerType && (
<Badge
variant="outline"
className="text-[10px] font-normal"
>
{buildServerType}
</Badge>
)}
</div>
)}
</div>
);
},
},
{
accessorKey: "title",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Title
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => (
<span className="text-sm truncate max-w-[200px] block">
{row.original.title || "—"}
</span>
),
},
{
accessorKey: "status",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Status
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => {
const status = row.original.status ?? "running";
return (
<Badge variant={statusVariants[status] ?? "secondary"}>
{status}
</Badge>
);
},
},
{
accessorKey: "createdAt",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Created
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => (
<span className="text-muted-foreground text-sm whitespace-nowrap">
{row.original.createdAt
? new Date(row.original.createdAt).toLocaleString()
: "—"}
</span>
),
},
{
header: "",
id: "actions",
enableSorting: false,
cell: ({ row }: { row: { original: DeploymentRow } }) => {
const info = getServiceInfo(row.original);
if (!info) return null;
return (
<Button variant="ghost" size="sm" asChild>
<Link href={info.href} className="gap-1">
<ExternalLink className="size-4" />
Open
</Link>
</Button>
);
},
},
],
[],
);
const table = useReactTable({
data: filteredData,
columns,
state: {
sorting,
columnFilters,
globalFilter,
pagination,
},
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by name, project, environment, server..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="running">Running</SelectItem>
<SelectItem value="done">Done</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="application">Application</SelectItem>
<SelectItem value="compose">Compose</SelectItem>
</SelectContent>
</Select>
</div>
<div className="px-0">
{isLoading ? (
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading deployments...</span>
</div>
) : (
<>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<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}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className=" text-center"
>
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
<Rocket className="size-8" />
<p className="font-medium">No deployments found</p>
<p className="text-sm">
Deployments from applications and compose will
appear here.
</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
Rows per page
</span>
<Select
value={String(pagination.pageSize)}
onValueChange={(value) => {
setPagination((p) => ({
...p,
pageSize: Number(value),
pageIndex: 0,
}));
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent side="top">
{[10, 25, 50, 100].map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground whitespace-nowrap">
Showing{" "}
{filteredData.length === 0
? 0
: pagination.pageIndex * pagination.pageSize + 1}{" "}
to{" "}
{Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
filteredData.length,
)}{" "}
of {filteredData.length} entries
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</>
)}
</div>
</div>
);
}
@@ -1,217 +0,0 @@
"use client";
import type { inferRouterOutputs } from "@trpc/server";
import { ArrowRight, ListTodo, Loader2, XCircle } from "lucide-react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AppRouter } from "@/server/api/root";
import { api } from "@/utils/api";
type QueueRow =
inferRouterOutputs<AppRouter>["deployment"]["queueList"][number];
const stateVariants: Record<
string,
| "default"
| "secondary"
| "destructive"
| "outline"
| "yellow"
| "green"
| "red"
> = {
pending: "secondary",
waiting: "secondary",
active: "yellow",
delayed: "outline",
completed: "green",
failed: "destructive",
cancelled: "outline",
paused: "outline",
};
function formatTs(ts?: number): string {
if (ts == null) return "—";
const d = new Date(ts);
return d.toLocaleString();
}
function getJobLabel(row: QueueRow): string {
const d = row.data as {
applicationType?: string;
applicationId?: string;
composeId?: string;
previewDeploymentId?: string;
titleLog?: string;
type?: string;
};
if (!d) return String(row.id);
const type = d.applicationType ?? "job";
const title = d.titleLog ?? "";
if (title) return title;
if (d.applicationId) return `Application ${d.applicationId.slice(0, 8)}`;
if (d.composeId) return `Compose ${d.composeId.slice(0, 8)}`;
if (d.previewDeploymentId)
return `Preview ${d.previewDeploymentId.slice(0, 8)}`;
return `${type} ${String(row.id)}`;
}
export function ShowQueueTable(props: { embedded?: boolean }) {
const { embedded: _embedded = false } = props;
const { data: queueList, isLoading } = api.deployment.queueList.useQuery(
undefined,
{ refetchInterval: 3000 },
);
const { data: isCloud } = api.settings.isCloud.useQuery();
const utils = api.useUtils();
const {
mutateAsync: cancelApplicationDeployment,
isPending: isCancellingApp,
} = api.application.cancelDeployment.useMutation({
onSuccess: () => void utils.deployment.queueList.invalidate(),
});
const {
mutateAsync: cancelComposeDeployment,
isPending: isCancellingCompose,
} = api.compose.cancelDeployment.useMutation({
onSuccess: () => void utils.deployment.queueList.invalidate(),
});
const isCancelling = isCancellingApp || isCancellingCompose;
return (
<div className="px-0">
{isLoading ? (
<div className="flex gap-4 w-full items-center justify-center min-h-[30vh] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading queue...</span>
</div>
) : (
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Job ID</TableHead>
<TableHead>Label</TableHead>
<TableHead>Type</TableHead>
<TableHead>State</TableHead>
<TableHead>Added</TableHead>
<TableHead>Processed</TableHead>
<TableHead>Finished</TableHead>
<TableHead>Error</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{queueList?.length ? (
queueList.map((row) => {
const d = row.data as Record<string, unknown>;
const appType = d?.applicationType as string | undefined;
const pathInfo = row.servicePath;
const hasLink = pathInfo?.href != null;
return (
<TableRow key={String(row.id)}>
<TableCell className="font-mono text-xs">
{String(row.id)}
</TableCell>
<TableCell className="max-w-[200px] truncate">
{getJobLabel(row)}
</TableCell>
<TableCell>{appType ?? row.name ?? "—"}</TableCell>
<TableCell>
<Badge variant={stateVariants[row.state] ?? "outline"}>
{row.state}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{formatTs(row.timestamp)}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{formatTs(row.processedOn)}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{formatTs(row.finishedOn)}
</TableCell>
<TableCell className="max-w-[180px] truncate text-xs text-destructive">
{row.failedReason ?? "—"}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{hasLink ? (
<Button variant="ghost" size="sm" asChild>
<Link href={pathInfo!.href!}>
<ArrowRight className="size-4 mr-1" />
Service
</Link>
</Button>
) : (
<span className="text-muted-foreground text-xs">
</span>
)}
{isCloud &&
row.state === "active" &&
(d?.applicationId != null ||
d?.composeId != null) && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={isCancelling}
onClick={() => {
const appId =
typeof d.applicationId === "string"
? d.applicationId
: undefined;
const compId =
typeof d.composeId === "string"
? d.composeId
: undefined;
if (appId) {
void cancelApplicationDeployment({
applicationId: appId,
});
} else if (compId) {
void cancelComposeDeployment({
composeId: compId,
});
}
}}
>
<XCircle className="size-4 mr-1" />
Cancel
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={9} className="text-center py-12">
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground min-h-[30vh]">
<ListTodo className="size-8" />
<p className="font-medium">Queue is empty</p>
<p className="text-sm">
Deployment jobs will appear here when they are queued.
</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
</div>
);
}
@@ -1,220 +0,0 @@
"use client";
import copy from "copy-to-clipboard";
import {
Bot,
Check,
Copy,
Loader2,
RotateCcw,
Settings,
X,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import type { LogLine } from "./utils";
interface Props {
logs: LogLine[];
context: "build" | "runtime";
}
const MAX_LOG_LINES = 200;
export function AnalyzeLogs({ logs, context }: Props) {
const [open, setOpen] = useState(false);
const [aiId, setAiId] = useState<string>("");
const [copied, setCopied] = useState(false);
const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, {
enabled: open,
});
const { mutate, isPending, data, reset } = api.ai.analyzeLogs.useMutation({
onError: (error) => {
toast.error("Analysis failed", {
description: error.message,
});
},
});
const handleAnalyze = () => {
if (!aiId || logs.length === 0) return;
const logsText = logs
.slice(-MAX_LOG_LINES)
.map((l) => l.message)
.join("\n");
mutate({ aiId, logs: logsText, context });
};
const handleCopy = () => {
if (!data?.analysis) return;
const success = copy(data.analysis);
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<Popover
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
if (!isOpen) {
reset();
setAiId("");
}
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-9"
disabled={logs.length === 0}
title="Analyze logs with AI"
>
<Bot className="mr-2 size-4" />
AI
</Button>
</PopoverTrigger>
<PopoverContent className="w-[550px] p-0" align="end">
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<Bot className="h-4 w-4" />
<span className="text-sm font-medium">Log Analysis</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setOpen(false)}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
<div className="p-4 space-y-3">
{!data?.analysis ? (
providers && providers.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-2 text-center">
<p className="text-sm text-muted-foreground">
No AI providers configured. Set up a provider to start
analyzing logs.
</p>
<Button size="sm" variant="outline" asChild>
<Link href="/dashboard/settings/ai">
<Settings className="mr-2 h-3.5 w-3.5" />
Configure AI Provider
</Link>
</Button>
</div>
) : (
<>
<Select value={aiId} onValueChange={setAiId}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select AI provider..." />
</SelectTrigger>
<SelectContent>
{providers?.map((p) => (
<SelectItem key={p.aiId} value={p.aiId}>
{p.name} ({p.model})
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
className="w-full"
disabled={!aiId || isPending || logs.length === 0}
onClick={handleAnalyze}
>
{isPending ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Analyzing...
</>
) : (
<>
<Bot className="mr-2 h-3.5 w-3.5" />
Analyze{" "}
{logs.length > MAX_LOG_LINES
? `last ${MAX_LOG_LINES}`
: logs.length}{" "}
lines
</>
)}
</Button>
</>
)
) : (
<>
<div className="max-h-[400px] overflow-y-auto">
<div className="prose prose-sm dark:prose-invert max-w-none text-sm break-words">
<ReactMarkdown>{data.analysis}</ReactMarkdown>
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={() => {
reset();
handleAnalyze();
}}
disabled={isPending}
>
{isPending ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="mr-2 h-3.5 w-3.5" />
)}
Re-analyze
</Button>
<Button
size="sm"
variant="outline"
onClick={handleCopy}
title="Copy analysis to clipboard"
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
reset();
setAiId("");
}}
title="Change provider"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
);
}
@@ -12,7 +12,6 @@ import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { AnalyzeLogs } from "./analyze-logs";
import { LineCountFilter } from "./line-count-filter";
import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter";
import { StatusLogsFilter } from "./status-logs-filter";
@@ -347,13 +346,11 @@ export const DockerLogsId: React.FC<Props> = ({
title={isPaused ? "Resume logs" : "Pause logs"}
>
{isPaused ? (
<Play className="size-4" />
<Play className="mr-2 h-4 w-4" />
) : (
<Pause className="size-4" />
<Pause className="mr-2 h-4 w-4" />
)}
<span className="hidden lg:ml-2 lg:inline">
{isPaused ? "Resume" : "Pause"}
</span>
{isPaused ? "Resume" : "Pause"}
</Button>
<Button
variant="outline"
@@ -364,13 +361,11 @@ export const DockerLogsId: React.FC<Props> = ({
title="Copy logs to clipboard"
>
{copied ? (
<Check className="size-4" />
<Check className="mr-2 h-4 w-4" />
) : (
<Copy className="size-4" />
<Copy className="mr-2 h-4 w-4" />
)}
<span className="hidden lg:ml-2 lg:inline">
{copied ? "Copied" : "Copy"}
</span>
Copy
</Button>
<Button
variant="outline"
@@ -378,18 +373,16 @@ export const DockerLogsId: React.FC<Props> = ({
className="h-9 sm:w-auto w-full"
onClick={handleDownload}
disabled={filteredLogs.length === 0 || !data?.Name}
title="Download logs as text file"
>
<DownloadIcon className="size-4" />
<span className="hidden lg:ml-2 lg:inline">Download logs</span>
<DownloadIcon className="mr-2 h-4 w-4" />
Download logs
</Button>
<AnalyzeLogs logs={filteredLogs} context="runtime" />
</div>
</div>
{isPaused && (
<AlertBlock type="warning" className="items-center">
<AlertBlock type="warning">
<div className="flex items-center gap-2">
<Pause className="size-4" />
<Pause className="h-4 w-4" />
<span>
Logs paused
{messageBuffer.length > 0 && (
@@ -103,7 +103,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
>
{" "}
<div className="flex items-start gap-x-2">
{/* Icon to expand the log item maybe implement a collapsible later */}
{/* Icon to expand the log item maybe implement a colapsible later */}
{/* <Square className="size-4 text-muted-foreground opacity-0 group-hover/logitem:opacity-100 transition-opacity" /> */}
{tooltip(color, rawTimestamp)}
{!noTimestamp && (
@@ -74,18 +74,6 @@ export function parseLogs(logString: string): LogLine[] {
// Detect log type based on message content
export const getLogType = (message: string): LogStyle => {
// Detect HTTP statusCode
const statusMatch = message.match(/"statusCode"\s*:\s*"?(\d{3})"?/);
if (statusMatch) {
const statusCode = Number(statusMatch[1]);
if (statusCode >= 500) return LOG_STYLES.error;
if (statusCode >= 400) return LOG_STYLES.warning;
if (statusCode >= 200 && statusCode < 300) return LOG_STYLES.success;
return LOG_STYLES.info;
}
const lowerMessage = message.toLowerCase();
if (
@@ -1,112 +0,0 @@
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
interface Props {
containerId: string;
serverId?: string;
}
interface Mount {
Type: string;
Source: string;
Destination: string;
Mode: string;
RW: boolean;
Propagation: string;
Name?: string;
Driver?: string;
}
export const ShowContainerMounts = ({ containerId, serverId }: Props) => {
const { data } = api.docker.getConfig.useQuery(
{
containerId,
serverId,
},
{
enabled: !!containerId,
},
);
const mounts: Mount[] = data?.Mounts ?? [];
return (
<Dialog>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
View Mounts
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Container Mounts</DialogTitle>
<DialogDescription>
Volume and bind mounts for this container
</DialogDescription>
</DialogHeader>
<div className="overflow-auto max-h-[70vh]">
{mounts.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
No mounts found for this container.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Source</TableHead>
<TableHead>Destination</TableHead>
<TableHead>Mode</TableHead>
<TableHead>Read/Write</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mounts.map((mount, index) => (
<TableRow key={index}>
<TableCell>
<Badge variant="outline">{mount.Type}</Badge>
</TableCell>
<TableCell className="font-mono text-xs max-w-[250px] truncate">
{mount.Name || mount.Source}
</TableCell>
<TableCell className="font-mono text-xs max-w-[250px] truncate">
{mount.Destination}
</TableCell>
<TableCell className="text-xs">
{mount.Mode || "-"}
</TableCell>
<TableCell>
<Badge variant={mount.RW ? "default" : "secondary"}>
{mount.RW ? "RW" : "RO"}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</DialogContent>
</Dialog>
);
};
@@ -1,119 +0,0 @@
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
interface Props {
containerId: string;
serverId?: string;
}
interface Network {
IPAMConfig: unknown;
Links: unknown;
Aliases: string[] | null;
MacAddress: string;
NetworkID: string;
EndpointID: string;
Gateway: string;
IPAddress: string;
IPPrefixLen: number;
IPv6Gateway: string;
GlobalIPv6Address: string;
GlobalIPv6PrefixLen: number;
DriverOpts: unknown;
}
export const ShowContainerNetworks = ({ containerId, serverId }: Props) => {
const { data } = api.docker.getConfig.useQuery(
{
containerId,
serverId,
},
{
enabled: !!containerId,
},
);
const networks: Record<string, Network> =
data?.NetworkSettings?.Networks ?? {};
const entries = Object.entries(networks);
return (
<Dialog>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
View Networks
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Container Networks</DialogTitle>
<DialogDescription>
Networks attached to this container
</DialogDescription>
</DialogHeader>
<div className="overflow-auto max-h-[70vh]">
{entries.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
No networks found for this container.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Network</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>Gateway</TableHead>
<TableHead>MAC Address</TableHead>
<TableHead>Aliases</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map(([name, network]) => (
<TableRow key={name}>
<TableCell>
<Badge variant="outline">{name}</Badge>
</TableCell>
<TableCell className="font-mono text-xs">
{network.IPAddress
? `${network.IPAddress}/${network.IPPrefixLen}`
: "-"}
</TableCell>
<TableCell className="font-mono text-xs">
{network.Gateway || "-"}
</TableCell>
<TableCell className="font-mono text-xs">
{network.MacAddress || "-"}
</TableCell>
<TableCell className="text-xs">
{network.Aliases?.join(", ") || "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</DialogContent>
</Dialog>
);
};
@@ -1,66 +0,0 @@
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { api } from "@/utils/api";
interface Props {
containerId: string;
serverId?: string;
}
export const RemoveContainerDialog = ({ containerId, serverId }: Props) => {
const utils = api.useUtils();
const { mutateAsync, isPending } = api.docker.removeContainer.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) => e.preventDefault()}
>
Remove Container
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove the container{" "}
<span className="font-semibold">{containerId}</span>. If the
container is running, it will be forcefully stopped and removed.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={isPending}
onClick={async () => {
await mutateAsync({ containerId, serverId })
.then(async () => {
toast.success("Container removed successfully");
await utils.docker.getContainers.invalidate();
})
.catch((err) => {
toast.error(err.message);
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
@@ -10,11 +10,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { ShowContainerConfig } from "../config/show-container-config";
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
import { ShowContainerMounts } from "../mounts/show-container-mounts";
import { ShowContainerNetworks } from "../networks/show-container-networks";
import { RemoveContainerDialog } from "../remove/remove-container";
import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
import { UploadFileModal } from "../upload/upload-file-modal";
import type { Container } from "./show-containers";
export const columns: ColumnDef<Container>[] = [
@@ -125,30 +121,12 @@ export const columns: ColumnDef<Container>[] = [
containerId={container.containerId}
serverId={container.serverId || ""}
/>
<ShowContainerMounts
containerId={container.containerId}
serverId={container.serverId || ""}
/>
<ShowContainerNetworks
containerId={container.containerId}
serverId={container.serverId || ""}
/>
<DockerTerminalModal
containerId={container.containerId}
serverId={container.serverId || ""}
>
Terminal
</DockerTerminalModal>
<UploadFileModal
containerId={container.containerId}
serverId={container.serverId || undefined}
>
Upload File
</UploadFileModal>
<RemoveContainerDialog
containerId={container.containerId}
serverId={container.serverId ?? undefined}
/>
</DropdownMenuContent>
</DropdownMenu>
);
@@ -35,7 +35,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { api, type RouterOutputs } from "@/utils/api";
import { columns } from "./columns";
import { columns } from "./colums";
export type Container = NonNullable<
RouterOutputs["docker"]["getContainers"]
>[0];
@@ -1,187 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Upload } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Dropzone } from "@/components/ui/dropzone";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import {
type UploadFileToContainer,
uploadFileToContainerSchema,
} from "@/utils/schema";
interface Props {
containerId: string;
serverId?: string;
children?: React.ReactNode;
}
export const UploadFileModal = ({ children, containerId, serverId }: Props) => {
const [open, setOpen] = useState(false);
const { mutateAsync: uploadFile, isPending: isLoading } =
api.docker.uploadFileToContainer.useMutation({
onSuccess: () => {
toast.success("File uploaded successfully");
setOpen(false);
form.reset();
},
onError: (error) => {
toast.error(error.message || "Failed to upload file to container");
},
});
const form = useForm({
resolver: zodResolver(uploadFileToContainerSchema),
defaultValues: {
containerId,
destinationPath: "/",
serverId: serverId || undefined,
},
});
const file = form.watch("file");
const onSubmit = async (values: UploadFileToContainer) => {
if (!values.file) {
toast.error("Please select a file to upload");
return;
}
const formData = new FormData();
formData.append("containerId", values.containerId);
formData.append("file", values.file);
formData.append("destinationPath", values.destinationPath);
if (values.serverId) {
formData.append("serverId", values.serverId);
}
await uploadFile(formData);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
{children}
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Upload File to Container
</DialogTitle>
<DialogDescription>
Upload a file directly into the container's filesystem
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="destinationPath"
render={({ field }) => (
<FormItem>
<FormLabel>Destination Path</FormLabel>
<FormControl>
<Input
{...field}
placeholder="/path/to/file"
className="font-mono"
/>
</FormControl>
<FormMessage />
<p className="text-xs text-muted-foreground">
Enter the full path where the file should be uploaded in the
container (e.g., /app/config.json)
</p>
</FormItem>
)}
/>
<FormField
control={form.control}
name="file"
render={({ field }) => (
<FormItem>
<FormLabel>File</FormLabel>
<FormControl>
<Dropzone
{...field}
dropMessage="Drop file here or click to browse"
onChange={(files) => {
if (files && files.length > 0) {
field.onChange(files[0]);
} else {
field.onChange(null);
}
}}
/>
</FormControl>
<FormMessage />
{file instanceof File && (
<div className="flex items-center gap-2 p-2 bg-muted rounded-md">
<span className="text-sm text-muted-foreground flex-1">
{file.name} ({(file.size / 1024).toFixed(2)} KB)
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => field.onChange(null)}
>
Remove
</Button>
</div>
)}
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
isLoading={isLoading}
disabled={!file || isLoading}
>
Upload File
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -1,291 +0,0 @@
import { formatDistanceToNow } from "date-fns";
import { ArrowRight, Rocket, Server } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { api } from "@/utils/api";
type DeploymentStatus = "idle" | "running" | "done" | "error";
const statusDotClass: Record<string, string> = {
done: "bg-emerald-500",
running: "bg-amber-500",
error: "bg-red-500",
idle: "bg-muted-foreground/40",
};
function getServiceInfo(d: any) {
const app = d.application;
const comp = d.compose;
const serverName: string =
d.server?.name ?? app?.server?.name ?? comp?.server?.name ?? "Dokploy";
if (app?.environment?.project && app.environment) {
return {
name: app.name as string,
environment: app.environment.name as string,
projectName: app.environment.project.name as string,
serverName,
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
};
}
if (comp?.environment?.project && comp.environment) {
return {
name: comp.name as string,
environment: comp.environment.name as string,
projectName: comp.environment.project.name as string,
serverName,
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
};
}
return null;
}
function StatCard({
label,
value,
delta,
}: {
label: string;
value: string;
delta?: string;
}) {
return (
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between">
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</span>
<div className="flex flex-col gap-1">
<span className="text-3xl font-semibold tracking-tight">{value}</span>
{delta && (
<span className="text-xs text-muted-foreground">{delta}</span>
)}
</div>
</div>
);
}
function StatusListCard({
label,
items,
}: {
label: string;
items: { dotClass: string; label: string; count: number }[];
}) {
return (
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col gap-3">
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</span>
<ul className="flex flex-col gap-1.5">
{items.map((item) => (
<li key={item.label} className="flex items-center gap-2.5 text-sm">
<span
className={`size-2 rounded-full shrink-0 ${item.dotClass}`}
aria-hidden
/>
<span className="font-semibold tabular-nums w-8">{item.count}</span>
<span className="text-muted-foreground">{item.label}</span>
</li>
))}
</ul>
</div>
);
}
export const ShowHome = () => {
const { data: auth } = api.user.get.useQuery();
const { data: homeStats } = api.project.homeStats.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const canReadDeployments = !!permissions?.deployment.read;
const { data: deployments } = api.deployment.allCentralized.useQuery(
undefined,
{
enabled: canReadDeployments,
refetchInterval: 10000,
},
);
const firstName = auth?.user?.firstName?.trim();
const totals = homeStats ?? {
projects: 0,
environments: 0,
applications: 0,
compose: 0,
databases: 0,
services: 0,
};
const statusBreakdown = homeStats?.status ?? {
running: 0,
error: 0,
idle: 0,
};
const recentDeployments = useMemo(() => {
if (!deployments) return [];
return [...deployments]
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)
.slice(0, 10);
}, [deployments]);
const deployStats = useMemo(() => {
const now = Date.now();
const weekMs = 7 * 24 * 60 * 60 * 1000;
const lastStart = now - weekMs;
const prevStart = now - 2 * weekMs;
const last: NonNullable<typeof deployments> = [];
const prev: NonNullable<typeof deployments> = [];
for (const d of deployments ?? []) {
const t = new Date(d.createdAt).getTime();
if (t >= lastStart) last.push(d);
else if (t >= prevStart) prev.push(d);
}
const lastCount = last.length;
const prevCount = prev.length;
let delta: string | undefined;
if (prevCount > 0) {
const pct = Math.round(((lastCount - prevCount) / prevCount) * 100);
delta = `${pct >= 0 ? "+" : ""}${pct}% vs prev 7d`;
} else if (lastCount > 0) {
delta = "no prior data";
} else {
delta = "no activity yet";
}
return { value: String(lastCount), delta };
}, [deployments]);
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl min-h-[85vh]">
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-6 h-full">
<div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
<h1 className="text-3xl font-semibold tracking-tight">
{firstName ? `Welcome back, ${firstName}` : "Welcome back"}
</h1>
<Button asChild variant="secondary" className="w-fit">
<Link href="/dashboard/projects">
Go to projects
<ArrowRight className="size-4" />
</Link>
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Projects"
value={String(totals.projects)}
delta={`${totals.environments} ${totals.environments === 1 ? "environment" : "environments"}`}
/>
<StatCard
label="Services"
value={String(totals.services)}
delta={`${totals.applications} apps · ${totals.compose} compose · ${totals.databases} db`}
/>
<StatCard
label="Deploys / 7d"
value={deployStats.value}
delta={deployStats.delta}
/>
<StatusListCard
label="Status"
items={[
{
dotClass: "bg-emerald-500",
label: "running",
count: statusBreakdown.running,
},
{
dotClass: "bg-red-500",
label: "errored",
count: statusBreakdown.error,
},
{
dotClass: "bg-muted-foreground/40",
label: "idle",
count: statusBreakdown.idle,
},
]}
/>
</div>
<div className="rounded-xl border bg-background">
<div className="flex items-center justify-between px-5 py-4 border-b">
<div className="flex items-center gap-2">
<Rocket className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Recent deployments</h2>
</div>
{canReadDeployments && (
<Link
href="/dashboard/deployments"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
view all
</Link>
)}
</div>
{!canReadDeployments ? (
<div className="min-h-[400px] flex flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground p-10">
<Rocket className="size-8 opacity-40" />
<span>You do not have permission to view deployments.</span>
</div>
) : recentDeployments.length === 0 ? (
<div className="min-h-[400px] flex flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground p-10">
<Rocket className="size-8 opacity-40" />
<span>No deployments yet.</span>
</div>
) : (
<ul className="divide-y">
{recentDeployments.map((d) => {
const info = getServiceInfo(d);
if (!info) return null;
const status = (d.status ?? "idle") as DeploymentStatus;
return (
<li key={d.deploymentId}>
<Link
href={info.href}
className="flex items-center gap-4 px-5 py-4 hover:bg-muted/40 transition-colors"
>
<span
className={`size-2 rounded-full shrink-0 ${statusDotClass[status] ?? statusDotClass.idle}`}
aria-hidden
/>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm truncate">{info.name}</span>
<span className="text-xs text-muted-foreground truncate">
{info.projectName} · {info.environment}
</span>
</div>
<span className="text-xs text-muted-foreground w-36 hidden lg:flex items-center justify-end gap-1.5 truncate">
<Server className="size-3 shrink-0" />
<span className="truncate">{info.serverName}</span>
</span>
<span className="text-xs text-muted-foreground w-20 text-right hidden sm:inline">
{status}
</span>
<span className="text-xs text-muted-foreground w-24 text-right hidden md:inline">
{formatDistanceToNow(new Date(d.createdAt), {
addSuffix: true,
})}
</span>
<span className="text-xs text-muted-foreground hover:text-foreground transition-colors">
logs
</span>
</Link>
</li>
);
})}
</ul>
)}
</div>
</div>
</Card>
</div>
);
};

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