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
415 changed files with 29707 additions and 215701 deletions

View File

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

View File

@@ -99,14 +99,7 @@ pnpm run dokploy:build
## Docker ## Docker
To build the docker image first run commands to copy .env files To build the docker image
```bash
cp apps/dokploy/.env.production.example .env.production
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
```
then run build command
```bash ```bash
pnpm run docker:build pnpm run docker:build

View File

@@ -19,7 +19,7 @@ Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies th
Dokploy includes multiple features to make your life easier. Dokploy includes multiple features to make your life easier.
- **Applications**: Deploy any type of application (Node.js, PHP, Python, Go, Ruby, etc.). - **Applications**: Deploy any type of application (Node.js, PHP, Python, Go, Ruby, etc.).
- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, 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. - **Backups**: Automate backups for databases to an external storage destination.
- **Docker Compose**: Native support for Docker Compose to manage complex applications. - **Docker Compose**: Native support for Docker Compose to manage complex applications.
- **Multi Node**: Scale applications to multiple nodes using Docker Swarm to manage the cluster. - **Multi Node**: Scale applications to multiple nodes using Docker Swarm to manage the cluster.
@@ -39,7 +39,7 @@ To get started, run the following command on a VPS:
Want to skip the installation process? [Try the Dokploy Cloud](https://app.dokploy.com). Want to skip the installation process? [Try the Dokploy Cloud](https://app.dokploy.com).
```bash ```bash
curl -sSL https://dokploy.com/install.sh | bash curl -sSL https://dokploy.com/install.sh | sh
``` ```
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com). For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -7,7 +7,6 @@ describe("createDomainLabels", () => {
const baseDomain: Domain = { const baseDomain: Domain = {
host: "example.com", host: "example.com",
port: 8080, port: 8080,
customEntrypoint: null,
https: false, https: false,
uniqueConfigKey: 1, uniqueConfigKey: 1,
customCertResolver: null, customCertResolver: null,
@@ -22,7 +21,6 @@ describe("createDomainLabels", () => {
previewDeploymentId: "", previewDeploymentId: "",
internalPath: "/", internalPath: "/",
stripPath: false, stripPath: false,
middlewares: null,
}; };
it("should create basic labels for web entrypoint", async () => { it("should create basic labels for web entrypoint", async () => {
@@ -173,12 +171,12 @@ describe("createDomainLabels", () => {
"websecure", "websecure",
); );
// Web entrypoint with HTTPS should only have redirect // Web entrypoint should have both middlewares with redirect first
expect(webLabels).toContain( 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( expect(websecureLabels).toContain(
"traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1", "traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1",
); );
@@ -210,9 +208,9 @@ describe("createDomainLabels", () => {
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello", "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( expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file", "traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,stripprefix-test-app-1,addprefix-test-app-1",
); );
}); });
@@ -242,259 +240,4 @@ describe("createDomainLabels", () => {
"traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1", "traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
); );
}); });
it("should add single custom middleware to router", async () => {
const customMiddlewareDomain = {
...baseDomain,
middlewares: ["auth@file"],
};
const labels = await createDomainLabels(
appName,
customMiddlewareDomain,
"web",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=auth@file",
);
});
it("should add multiple custom middlewares to router", async () => {
const customMiddlewareDomain = {
...baseDomain,
middlewares: ["auth@file", "rate-limit@file"],
};
const labels = await createDomainLabels(
appName,
customMiddlewareDomain,
"web",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=auth@file,rate-limit@file",
);
});
it("should only have redirect on web router when HTTPS is enabled with custom middlewares", async () => {
const combinedDomain = {
...baseDomain,
https: true,
middlewares: ["auth@file"],
};
const labels = await createDomainLabels(appName, combinedDomain, "web");
// Web router with HTTPS should only redirect, custom middlewares go on websecure
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
expect(labels).not.toContain("auth@file");
});
it("should combine custom middlewares with stripPath middleware (no HTTPS)", async () => {
const combinedDomain = {
...baseDomain,
path: "/api",
stripPath: true,
middlewares: ["auth@file"],
};
const labels = await createDomainLabels(appName, combinedDomain, "web");
// stripprefix should come before custom middleware
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=stripprefix-test-app-1,auth@file",
);
});
it("should only have redirect on web router even with all built-in middlewares and custom middlewares", async () => {
const fullDomain = {
...baseDomain,
https: true,
path: "/api",
stripPath: true,
internalPath: "/hello",
middlewares: ["auth@file", "rate-limit@file"],
};
const webLabels = await createDomainLabels(appName, fullDomain, "web");
// Web router with HTTPS should only redirect
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
// Middleware definitions should still be present (Traefik needs them registered)
expect(webLabels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(webLabels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
// But they should NOT be attached to the router
expect(webLabels).not.toContain("stripprefix-test-app-1,");
expect(webLabels).not.toContain("auth@file");
expect(webLabels).not.toContain("rate-limit@file");
});
it("should include custom middlewares on websecure entrypoint", async () => {
const customMiddlewareDomain = {
...baseDomain,
https: true,
middlewares: ["auth@file"],
};
const websecureLabels = await createDomainLabels(
appName,
customMiddlewareDomain,
"websecure",
);
// Websecure should have custom middleware but not redirect-to-https
expect(websecureLabels).toContain(
"traefik.http.routers.test-app-1-websecure.middlewares=auth@file",
);
expect(websecureLabels).not.toContain("redirect-to-https");
});
it("should NOT include custom middlewares on web router when HTTPS is enabled (only redirect)", async () => {
const domain = {
...baseDomain,
https: true,
middlewares: ["rate-limit@file", "auth@file"],
};
const webLabels = await createDomainLabels(appName, domain, "web");
// Web router with HTTPS should ONLY have redirect, not custom middlewares
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
expect(webLabels).not.toContain("rate-limit@file");
expect(webLabels).not.toContain("auth@file");
});
it("should create basic labels for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{ ...baseDomain, customEntrypoint: "custom" },
"custom",
);
expect(labels).toEqual([
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
]);
});
it("should create https labels for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
https: true,
customEntrypoint: "custom",
certificateType: "letsencrypt",
},
"custom",
);
expect(labels).toEqual([
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
"traefik.http.routers.test-app-1-custom.tls.certresolver=letsencrypt",
]);
});
it("should add stripPath middleware for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1",
);
});
it("should add internalPath middleware for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
internalPath: "/hello",
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=addprefix-test-app-1",
);
});
it("should add path prefix in rule for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
},
"custom",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`) && PathPrefix(`/api`)",
);
});
it("should combine all middlewares for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
internalPath: "/hello",
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(labels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
);
});
it("should not add redirect-to-https for custom entrypoint even with https", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
https: true,
certificateType: "letsencrypt",
},
"custom",
);
const middlewareLabel = labels.find((l) => l.includes(".middlewares="));
// Should not contain redirect-to-https since there's only one router
expect(middlewareLabel).toBeUndefined();
});
}); });

View File

@@ -292,7 +292,7 @@ networks:
dokploy-network: dokploy-network:
`; `;
test("It shouldn't add suffix to dokploy-network", () => { test("It shoudn't add suffix to dokploy-network", () => {
const composeData = parse(composeFile7) as ComposeSpecification; const composeData = parse(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash(); const suffix = generateRandomHash();

View File

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

View File

@@ -415,24 +415,5 @@ describe("Docker Image Name and Tag Extraction", () => {
expect(extractImageTag("my-image:123")).toBe("123"); expect(extractImageTag("my-image:123")).toBe("123");
expect(extractImageTag("my-image:1")).toBe("1"); expect(extractImageTag("my-image:1")).toBe("1");
}); });
it("should return 'latest' for registry with port but no tag", () => {
expect(extractImageTag("registry.example.com:5000/myimage")).toBe(
"latest",
);
expect(extractImageTag("registry:5000/fedora/httpd")).toBe("latest");
expect(extractImageTag("localhost:5000/myapp")).toBe("latest");
expect(extractImageTag("my-registry.io:443/org/app")).toBe("latest");
});
it("should extract tag from registry with port and tag", () => {
expect(extractImageTag("registry:5000/image:tag")).toBe("tag");
expect(extractImageTag("registry.example.com:5000/myimage:v2.0")).toBe(
"v2.0",
);
expect(extractImageTag("localhost:5000/app:sha-abc123")).toBe(
"sha-abc123",
);
});
}); });
}); });

View File

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

View File

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

View File

@@ -1,144 +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("static roles bypass enterprise resources", () => {
it("owner bypasses deployment.read", async () => {
memberToReturn = mockMemberData("owner");
await expect(
checkPermission(ctx, { deployment: ["read"] }),
).resolves.toBeUndefined();
});
it("admin bypasses backup.create", async () => {
memberToReturn = mockMemberData("admin");
await expect(
checkPermission(ctx, { backup: ["create"] }),
).resolves.toBeUndefined();
});
it("member bypasses schedule.delete", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { schedule: ["delete"] }),
).resolves.toBeUndefined();
});
it("member bypasses multiple enterprise permissions at once", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, {
deployment: ["read"],
backup: ["create"],
domain: ["delete"],
}),
).resolves.toBeUndefined();
});
});
describe("static roles validate free-tier resources", () => {
it("owner passes project.create", async () => {
memberToReturn = mockMemberData("owner");
await expect(
checkPermission(ctx, { project: ["create"] }),
).resolves.toBeUndefined();
});
it("member fails project.create (no legacy override)", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { project: ["create"] }),
).rejects.toThrow();
});
it("member passes service.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { service: ["read"] }),
).resolves.toBeUndefined();
});
it("member fails service.create", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { service: ["create"] }),
).rejects.toThrow();
});
});
describe("legacy boolean overrides for member", () => {
it("member passes project.create with canCreateProjects=true", async () => {
memberToReturn = mockMemberData("member", { canCreateProjects: true });
await expect(
checkPermission(ctx, { project: ["create"] }),
).resolves.toBeUndefined();
});
it("member passes docker.read with canAccessToDocker=true", async () => {
memberToReturn = mockMemberData("member", { canAccessToDocker: true });
await expect(
checkPermission(ctx, { docker: ["read"] }),
).resolves.toBeUndefined();
});
it("member fails docker.read with canAccessToDocker=false", async () => {
memberToReturn = mockMemberData("member");
await expect(checkPermission(ctx, { docker: ["read"] })).rejects.toThrow();
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -95,7 +95,6 @@ const baseApp: ApplicationNested = {
dropBuildPath: null, dropBuildPath: null,
enabled: null, enabled: null,
env: null, env: null,
icon: null,
healthCheckSwarm: null, healthCheckSwarm: null,
labelsSwarm: null, labelsSwarm: null,
memoryLimit: null, memoryLimit: null,
@@ -138,7 +137,6 @@ const baseDomain: Domain = {
https: false, https: false,
path: null, path: null,
port: null, port: null,
customEntrypoint: null,
serviceName: "", serviceName: "",
composeId: "", composeId: "",
customCertResolver: null, customCertResolver: null,
@@ -147,7 +145,6 @@ const baseDomain: Domain = {
previewDeploymentId: "", previewDeploymentId: "",
internalPath: "/", internalPath: "/",
stripPath: false, stripPath: false,
middlewares: null,
}; };
const baseRedirect: Redirect = { const baseRedirect: Redirect = {
@@ -267,80 +264,6 @@ test("Websecure entrypoint on https domain with redirect", async () => {
expect(router.middlewares).toContain("redirect-test-1"); expect(router.middlewares).toContain("redirect-test-1");
}); });
/** Custom Middlewares */
test("Web entrypoint with single custom middleware", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, middlewares: ["auth@file"] },
"web",
);
expect(router.middlewares).toContain("auth@file");
});
test("Web entrypoint with multiple custom middlewares", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, middlewares: ["auth@file", "rate-limit@file"] },
"web",
);
expect(router.middlewares).toContain("auth@file");
expect(router.middlewares).toContain("rate-limit@file");
});
test("Web entrypoint on https domain with custom middleware", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
"web",
);
// Should only have HTTPS redirect - custom middleware applies on websecure
expect(router.middlewares).toContain("redirect-to-https");
expect(router.middlewares).not.toContain("auth@file");
});
test("Websecure entrypoint with custom middleware", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
"websecure",
);
// Should have custom middleware but not HTTPS redirect
expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.middlewares).toContain("auth@file");
});
test("Web entrypoint with redirect and custom middleware", async () => {
const router = await createRouterConfig(
{
...baseApp,
appName: "test",
redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }],
},
{ ...baseDomain, middlewares: ["auth@file"] },
"web",
);
// Should have both redirect middleware and custom middleware
expect(router.middlewares).toContain("redirect-test-1");
expect(router.middlewares).toContain("auth@file");
});
test("Web entrypoint with empty middlewares array", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: false, middlewares: [] },
"web",
);
// Should behave same as no middlewares - no redirect for http
expect(router.middlewares).not.toContain("redirect-to-https");
});
/** Certificates */ /** Certificates */
test("CertificateType on websecure entrypoint", async () => { test("CertificateType on websecure entrypoint", async () => {
@@ -353,110 +276,6 @@ test("CertificateType on websecure entrypoint", async () => {
expect(router.tls?.certResolver).toBe("letsencrypt"); expect(router.tls?.certResolver).toBe("letsencrypt");
}); });
test("Custom entrypoint on http domain", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: false, customEntrypoint: "custom" },
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.tls).toBeUndefined();
});
test("Custom entrypoint on https domain", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
https: true,
customEntrypoint: "custom",
certificateType: "letsencrypt",
},
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.tls?.certResolver).toBe("letsencrypt");
});
test("Custom entrypoint with path includes PathPrefix in rule", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, customEntrypoint: "custom", path: "/api" },
"custom",
);
expect(router.rule).toContain("PathPrefix(`/api`)");
expect(router.entryPoints).toEqual(["custom"]);
});
test("Custom entrypoint with stripPath adds stripprefix middleware", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
},
"custom",
);
expect(router.middlewares).toContain("stripprefix--1");
expect(router.entryPoints).toEqual(["custom"]);
});
test("Custom entrypoint with internalPath adds addprefix middleware", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
customEntrypoint: "custom",
internalPath: "/hello",
},
"custom",
);
expect(router.middlewares).toContain("addprefix--1");
expect(router.entryPoints).toEqual(["custom"]);
});
test("Custom entrypoint with https and custom cert resolver", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
https: true,
customEntrypoint: "custom",
certificateType: "custom",
customCertResolver: "myresolver",
},
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.tls?.certResolver).toBe("myresolver");
});
test("Custom entrypoint without https should not have tls", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
https: false,
customEntrypoint: "custom",
certificateType: "letsencrypt",
},
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.tls).toBeUndefined();
});
/** IDN/Punycode */ /** IDN/Punycode */
test("Internationalized domain name is converted to punycode", async () => { test("Internationalized domain name is converted to punycode", async () => {

View File

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

View File

@@ -37,27 +37,27 @@ import { AddSwarmSettings } from "./modify-swarm-settings";
interface Props { interface Props {
id: string; 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"), replicas: z.number().min(1, "Replicas must be at least 1"),
registryId: z.string().optional(), registryId: z.string().optional(),
}); });
type AddCommand = z.infer<typeof AddRedirectSchema>; type AddCommand = z.infer<typeof AddRedirectchema>;
export const ShowClusterSettings = ({ id, type }: Props) => { export const ShowClusterSettings = ({ id, type }: Props) => {
const queryMap = { const queryMap = {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
postgres: () => postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }), redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
}; };
const { data, refetch } = queryMap[type] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
@@ -65,13 +65,12 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
const { data: registries } = api.registry.all.useQuery(); const { data: registries } = api.registry.all.useQuery();
const mutationMap = { const mutationMap = {
application: () => api.application.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
postgres: () => api.postgres.update.useMutation(), postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(), redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
}; };
const { mutateAsync, isPending } = mutationMap[type] const { mutateAsync, isPending } = mutationMap[type]
@@ -87,7 +86,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
: {}), : {}),
replicas: data?.replicas || 1, replicas: data?.replicas || 1,
}, },
resolver: zodResolver(AddRedirectSchema), resolver: zodResolver(AddRedirectchema),
}); });
useEffect(() => { useEffect(() => {
@@ -106,11 +105,11 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
const onSubmit = async (data: AddCommand) => { const onSubmit = async (data: AddCommand) => {
await mutateAsync({ await mutateAsync({
applicationId: id || "", applicationId: id || "",
mariadbId: id || "",
mongoId: id || "",
mysqlId: id || "",
postgresId: id || "", postgresId: id || "",
redisId: id || "", redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
...(type === "application" ...(type === "application"
? { ? {
registryId: registryId:

View File

@@ -28,14 +28,7 @@ export const endpointSpecFormSchema = z.object({
interface EndpointSpecFormProps { interface EndpointSpecFormProps {
id: string; id: string;
type: type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
} }
export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => { export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
@@ -51,7 +44,6 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
application: () => application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
}; };
const { data, refetch } = queryMap[type] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
@@ -64,7 +56,6 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
mariadb: () => api.mariadb.update.useMutation(), mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(), application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(), mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
}; };
const { mutateAsync } = mutationMap[type] const { mutateAsync } = mutationMap[type]
@@ -103,7 +94,6 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
mysqlId: id || "", mysqlId: id || "",
mariadbId: id || "", mariadbId: id || "",
mongoId: id || "", mongoId: id || "",
libsqlId: id || "",
endpointSpecSwarm: hasAnyValue ? formData : null, endpointSpecSwarm: hasAnyValue ? formData : null,
}); });

View File

@@ -26,14 +26,7 @@ export const healthCheckFormSchema = z.object({
interface HealthCheckFormProps { interface HealthCheckFormProps {
id: string; id: string;
type: type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
} }
export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => { export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
@@ -49,7 +42,6 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
application: () => application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
}; };
const { data, refetch } = queryMap[type] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
@@ -62,7 +54,6 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
mariadb: () => api.mariadb.update.useMutation(), mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(), application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(), mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
}; };
const { mutateAsync } = mutationMap[type] const { mutateAsync } = mutationMap[type]
@@ -113,7 +104,6 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
mysqlId: id || "", mysqlId: id || "",
mariadbId: id || "", mariadbId: id || "",
mongoId: id || "", mongoId: id || "",
libsqlId: id || "",
healthCheckSwarm: hasAnyValue ? formData : null, healthCheckSwarm: hasAnyValue ? formData : null,
}); });

View File

@@ -29,14 +29,7 @@ export const labelsFormSchema = z.object({
interface LabelsFormProps { interface LabelsFormProps {
id: string; id: string;
type: type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
} }
export const LabelsForm = ({ id, type }: LabelsFormProps) => { export const LabelsForm = ({ id, type }: LabelsFormProps) => {
@@ -52,7 +45,6 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
application: () => application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
}; };
const { data, refetch } = queryMap[type] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
@@ -65,7 +57,6 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
mariadb: () => api.mariadb.update.useMutation(), mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(), application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(), mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
}; };
const { mutateAsync } = mutationMap[type] const { mutateAsync } = mutationMap[type]
@@ -121,7 +112,6 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
mysqlId: id || "", mysqlId: id || "",
mariadbId: id || "", mariadbId: id || "",
mongoId: id || "", mongoId: id || "",
libsqlId: id || "",
labelsSwarm: labelsToSend, labelsSwarm: labelsToSend,
}); });

View File

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

View File

@@ -35,14 +35,7 @@ export const networkFormSchema = z.object({
interface NetworkFormProps { interface NetworkFormProps {
id: string; id: string;
type: type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
} }
export const NetworkForm = ({ id, type }: NetworkFormProps) => { export const NetworkForm = ({ id, type }: NetworkFormProps) => {
@@ -58,7 +51,6 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
application: () => application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
}; };
const { data, refetch } = queryMap[type] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
@@ -71,7 +63,6 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
mariadb: () => api.mariadb.update.useMutation(), mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(), application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(), mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
}; };
const { mutateAsync } = mutationMap[type] const { mutateAsync } = mutationMap[type]
@@ -141,7 +132,6 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
mysqlId: id || "", mysqlId: id || "",
mariadbId: id || "", mariadbId: id || "",
mongoId: id || "", mongoId: id || "",
libsqlId: id || "",
networkSwarm: networksToSend, networkSwarm: networksToSend,
}); });

View File

@@ -34,14 +34,7 @@ export const placementFormSchema = z.object({
interface PlacementFormProps { interface PlacementFormProps {
id: string; id: string;
type: type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
} }
export const PlacementForm = ({ id, type }: PlacementFormProps) => { export const PlacementForm = ({ id, type }: PlacementFormProps) => {
@@ -57,7 +50,6 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
application: () => application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
}; };
const { data, refetch } = queryMap[type] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
@@ -70,7 +62,6 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
mariadb: () => api.mariadb.update.useMutation(), mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(), application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(), mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
}; };
const { mutateAsync } = mutationMap[type] const { mutateAsync } = mutationMap[type]
@@ -123,7 +114,6 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
mysqlId: id || "", mysqlId: id || "",
mariadbId: id || "", mariadbId: id || "",
mongoId: id || "", mongoId: id || "",
libsqlId: id || "",
placementSwarm: hasAnyValue placementSwarm: hasAnyValue
? { ? {
...formData, ...formData,

View File

@@ -32,14 +32,7 @@ export const restartPolicyFormSchema = z.object({
interface RestartPolicyFormProps { interface RestartPolicyFormProps {
id: string; id: string;
type: type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
} }
export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => { export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
@@ -55,7 +48,6 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
application: () => application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
}; };
const { data, refetch } = queryMap[type] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
@@ -68,7 +60,6 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
mariadb: () => api.mariadb.update.useMutation(), mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(), application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(), mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
}; };
const { mutateAsync } = mutationMap[type] const { mutateAsync } = mutationMap[type]
@@ -113,7 +104,6 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
mysqlId: id || "", mysqlId: id || "",
mariadbId: id || "", mariadbId: id || "",
mongoId: id || "", mongoId: id || "",
libsqlId: id || "",
restartPolicySwarm: hasAnyValue ? formData : null, restartPolicySwarm: hasAnyValue ? formData : null,
}); });

View File

@@ -34,14 +34,7 @@ export const rollbackConfigFormSchema = z.object({
interface RollbackConfigFormProps { interface RollbackConfigFormProps {
id: string; id: string;
type: type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
} }
export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => { export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
@@ -57,7 +50,6 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
application: () => application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
}; };
const { data, refetch } = queryMap[type] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
@@ -70,7 +62,6 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
mariadb: () => api.mariadb.update.useMutation(), mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(), application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(), mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
}; };
const { mutateAsync } = mutationMap[type] const { mutateAsync } = mutationMap[type]
@@ -112,7 +103,6 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
mysqlId: id || "", mysqlId: id || "",
mariadbId: id || "", mariadbId: id || "",
mongoId: id || "", mongoId: id || "",
libsqlId: id || "",
rollbackConfigSwarm: (hasAnyValue ? formData : null) as any, rollbackConfigSwarm: (hasAnyValue ? formData : null) as any,
}); });

View File

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

View File

@@ -34,14 +34,7 @@ export const updateConfigFormSchema = z.object({
interface UpdateConfigFormProps { interface UpdateConfigFormProps {
id: string; id: string;
type: type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
| "postgres"
| "mariadb"
| "mongo"
| "mysql"
| "redis"
| "application"
| "libsql";
} }
export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => { export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
@@ -57,7 +50,6 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
application: () => application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
}; };
const { data, refetch } = queryMap[type] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
@@ -70,7 +62,6 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
mariadb: () => api.mariadb.update.useMutation(), mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(), application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(), mongo: () => api.mongo.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
}; };
const { mutateAsync } = mutationMap[type] const { mutateAsync } = mutationMap[type]
@@ -118,7 +109,6 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
mysqlId: id || "", mysqlId: id || "",
mariadbId: id || "", mariadbId: id || "",
mongoId: id || "", mongoId: id || "",
libsqlId: id || "",
updateConfigSwarm: (hasAnyValue ? formData : null) as any, updateConfigSwarm: (hasAnyValue ? formData : null) as any,
}); });

View File

@@ -37,13 +37,13 @@ import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
const AddRedirectSchema = z.object({ const AddRedirectchema = z.object({
regex: z.string().min(1, "Regex required"), regex: z.string().min(1, "Regex required"),
permanent: z.boolean().default(false), permanent: z.boolean().default(false),
replacement: z.string().min(1, "Replacement required"), replacement: z.string().min(1, "Replacement required"),
}); });
type AddRedirect = z.infer<typeof AddRedirectSchema>; type AddRedirect = z.infer<typeof AddRedirectchema>;
// Default presets // Default presets
const redirectPresets = [ const redirectPresets = [
@@ -110,7 +110,7 @@ export const HandleRedirect = ({
regex: "", regex: "",
replacement: "", replacement: "",
}, },
resolver: zodResolver(AddRedirectSchema), resolver: zodResolver(AddRedirectchema),
}); });
useEffect(() => { useEffect(() => {
@@ -149,7 +149,7 @@ export const HandleRedirect = ({
const onDialogToggle = (open: boolean) => { const onDialogToggle = (open: boolean) => {
setIsOpen(open); setIsOpen(open);
// commented for the moment because not 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(""); // setPresetSelected("");
// form.reset(); // form.reset();
}; };

View File

@@ -89,13 +89,12 @@ const ULIMIT_PRESETS = [
]; ];
export type ServiceType = export type ServiceType =
| "application"
| "libsql"
| "mariadb"
| "mongo"
| "mysql"
| "postgres" | "postgres"
| "redis"; | "mongo"
| "redis"
| "mysql"
| "mariadb"
| "application";
interface Props { interface Props {
id: string; id: string;
@@ -106,29 +105,27 @@ type AddResources = z.infer<typeof addResourcesSchema>;
export const ShowResources = ({ id, type }: Props) => { export const ShowResources = ({ id, type }: Props) => {
const queryMap = { const queryMap = {
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
postgres: () => postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }), redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
}; };
const { data, refetch } = queryMap[type] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = { const mutationMap = {
application: () => api.application.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
postgres: () => api.postgres.update.useMutation(), postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(), redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
}; };
const { mutateAsync, isPending } = mutationMap[type] const { mutateAsync, isPending } = mutationMap[type]
@@ -158,20 +155,19 @@ export const ShowResources = ({ id, type }: Props) => {
cpuReservation: data?.cpuReservation || undefined, cpuReservation: data?.cpuReservation || undefined,
memoryLimit: data?.memoryLimit || undefined, memoryLimit: data?.memoryLimit || undefined,
memoryReservation: data?.memoryReservation || undefined, memoryReservation: data?.memoryReservation || undefined,
ulimitsSwarm: (data as any)?.ulimitsSwarm || [], ulimitsSwarm: data?.ulimitsSwarm || [],
}); });
} }
}, [data, form, form.reset]); }, [data, form, form.reset]);
const onSubmit = async (formData: AddResources) => { const onSubmit = async (formData: AddResources) => {
await mutateAsync({ await mutateAsync({
applicationId: id || "",
libsqlId: id || "",
mariadbId: id || "",
mongoId: id || "", mongoId: id || "",
mysqlId: id || "",
postgresId: id || "", postgresId: id || "",
redisId: id || "", redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
applicationId: id || "",
cpuLimit: formData.cpuLimit || null, cpuLimit: formData.cpuLimit || null,
cpuReservation: formData.cpuReservation || null, cpuReservation: formData.cpuReservation || null,
memoryLimit: formData.memoryLimit || null, memoryLimit: formData.memoryLimit || null,

View File

@@ -15,17 +15,13 @@ interface Props {
} }
export const ShowTraefikConfig = ({ applicationId }: 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( const { data, isPending } = api.application.readTraefikConfig.useQuery(
{ {
applicationId, applicationId,
}, },
{ enabled: !!applicationId && canRead }, { enabled: !!applicationId },
); );
if (!canRead) return null;
return ( return (
<Card className="bg-background"> <Card className="bg-background">
<CardHeader className="flex flex-row justify-between"> <CardHeader className="flex flex-row justify-between">

View File

@@ -60,8 +60,6 @@ export const validateAndFormatYAML = (yamlText: string) => {
}; };
export const UpdateTraefikConfig = ({ applicationId }: Props) => { export const UpdateTraefikConfig = ({ applicationId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canWrite = permissions?.traefikFiles.write ?? false;
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [skipYamlValidation, setSkipYamlValidation] = useState(false); const [skipYamlValidation, setSkipYamlValidation] = useState(false);
const { data, refetch } = api.application.readTraefikConfig.useQuery( const { data, refetch } = api.application.readTraefikConfig.useQuery(
@@ -127,11 +125,9 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
} }
}} }}
> >
{canWrite && ( <DialogTrigger asChild>
<DialogTrigger asChild> <Button isLoading={isPending}>Modify</Button>
<Button isLoading={isPending}>Modify</Button> </DialogTrigger>
</DialogTrigger>
)}
<DialogContent className="sm:max-w-4xl"> <DialogContent className="sm:max-w-4xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Update traefik config</DialogTitle> <DialogTitle>Update traefik config</DialogTitle>

View File

@@ -34,13 +34,13 @@ interface Props {
serviceId: string; serviceId: string;
serviceType: serviceType:
| "application" | "application"
| "compose"
| "libsql"
| "mariadb"
| "mongo"
| "mysql"
| "postgres" | "postgres"
| "redis"; | "redis"
| "mongo"
| "redis"
| "mysql"
| "mariadb"
| "compose";
refetch: () => void; refetch: () => void;
children?: React.ReactNode; children?: React.ReactNode;
} }

View File

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

View File

@@ -67,13 +67,13 @@ interface Props {
refetch: () => void; refetch: () => void;
serviceType: serviceType:
| "application" | "application"
| "compose"
| "libsql"
| "mariadb"
| "mongo"
| "mysql"
| "postgres" | "postgres"
| "redis"; | "redis"
| "mongo"
| "redis"
| "mysql"
| "mariadb"
| "compose";
} }
export const UpdateVolume = ({ export const UpdateVolume = ({
@@ -253,7 +253,7 @@ export const UpdateVolume = ({
control={form.control} control={form.control}
name="content" name="content"
render={({ field }) => ( render={({ field }) => (
<FormItem className="w-full max-w-[45rem]"> <FormItem className="max-w-full max-w-[45rem]">
<FormLabel>Content</FormLabel> <FormLabel>Content</FormLabel>
<FormControl> <FormControl>
<FormControl> <FormControl>

View File

@@ -1,9 +1,7 @@
import copy from "copy-to-clipboard";
import { import {
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
Clock, Clock,
Copy,
Loader2, Loader2,
RefreshCcw, RefreshCcw,
RocketIcon, RocketIcon,
@@ -99,12 +97,6 @@ export const ShowDeployments = ({
new Set(), new Set(),
); );
const webhookUrl = useMemo(
() =>
`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`,
[url, refreshToken, type],
);
const MAX_DESCRIPTION_LENGTH = 200; const MAX_DESCRIPTION_LENGTH = 200;
const truncateDescription = (description: string): string => { const truncateDescription = (description: string): string => {
@@ -232,27 +224,11 @@ export const ShowDeployments = ({
<div className="flex flex-row items-center gap-2 flex-wrap"> <div className="flex flex-row items-center gap-2 flex-wrap">
<span>Webhook URL: </span> <span>Webhook URL: </span>
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<Badge <span className="break-all text-muted-foreground">
role="button" {`${url}/api/deploy${
tabIndex={0} type === "compose" ? "/compose" : ""
aria-label="Copy webhook URL to clipboard" }/${refreshToken}`}
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" </span>
variant="outline"
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
copy(webhookUrl);
toast.success("Copied to clipboard.");
}
}}
onClick={() => {
copy(webhookUrl);
toast.success("Copied to clipboard.");
}}
>
{webhookUrl}
<Copy className="h-4 w-4 ml-2" />
</Badge>
{(type === "application" || type === "compose") && ( {(type === "application" || type === "compose") && (
<RefreshToken id={id} type={type} /> <RefreshToken id={id} type={type} />
)} )}

View File

@@ -1,303 +0,0 @@
import type { ColumnDef } from "@tanstack/react-table";
import {
ArrowUpDown,
CheckCircle2,
ExternalLink,
Loader2,
PenBoxIcon,
RefreshCw,
Server,
Trash2,
XCircle,
} from "lucide-react";
import Link from "next/link";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { RouterOutputs } from "@/utils/api";
import type { ValidationStates } from "./show-domains";
import { AddDomain } from "./handle-domain";
import { DnsHelperModal } from "./dns-helper-modal";
export type Domain =
| RouterOutputs["domain"]["byApplicationId"][0]
| RouterOutputs["domain"]["byComposeId"][0];
interface ColumnsProps {
id: string;
type: "application" | "compose";
validationStates: ValidationStates;
handleValidateDomain: (host: string) => Promise<void>;
handleDeleteDomain: (domainId: string) => Promise<void>;
isDeleting: boolean;
serverIp?: string;
canCreateDomain: boolean;
canDeleteDomain: boolean;
}
export const createColumns = ({
id,
type,
validationStates,
handleValidateDomain,
handleDeleteDomain,
isDeleting,
serverIp,
canCreateDomain,
canDeleteDomain,
}: ColumnsProps): ColumnDef<Domain>[] => [
...(type === "compose"
? [
{
accessorKey: "serviceName",
header: "Service",
cell: ({ row }: { row: { getValue: (key: string) => unknown } }) => {
const serviceName = row.getValue("serviceName") as string | null;
if (!serviceName) return null;
return (
<Badge variant="outline">
<Server className="size-3 mr-1" />
{serviceName}
</Badge>
);
},
} satisfies ColumnDef<Domain>,
]
: []),
{
accessorKey: "host",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Host
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const domain = row.original;
return (
<Link
className="flex items-center gap-2 font-medium hover:underline"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
>
{domain.host}
<ExternalLink className="size-3" />
</Link>
);
},
},
{
accessorKey: "path",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Path
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const path = row.getValue("path") as string;
return <div className="font-mono text-sm">{path || "/"}</div>;
},
},
{
accessorKey: "port",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Port
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const port = row.getValue("port") as number;
return <Badge variant="secondary">{port}</Badge>;
},
},
{
accessorKey: "customEntrypoint",
header: "Entrypoint",
cell: ({ row }) => {
const entrypoint = row.getValue("customEntrypoint") as string | null;
if (!entrypoint) return <span className="text-muted-foreground">-</span>;
return <div className="font-mono text-sm">{entrypoint}</div>;
},
},
{
accessorKey: "https",
header: "Protocol",
cell: ({ row }) => {
const https = row.getValue("https") as boolean;
return (
<Badge variant={https ? "outline" : "secondary"}>
{https ? "HTTPS" : "HTTP"}
</Badge>
);
},
},
{
id: "certificate",
header: "Certificate",
cell: ({ row }) => {
const domain = row.original;
const validationState = validationStates[domain.host];
return (
<div className="flex items-center gap-2">
{domain.certificateType && (
<Badge variant="outline" className="capitalize">
{domain.certificateType}
</Badge>
)}
{!domain.host.includes("traefik.me") && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className={
validationState?.isValid
? "bg-green-500/10 text-green-500 cursor-pointer"
: validationState?.error
? "bg-red-500/10 text-red-500 cursor-pointer"
: "bg-yellow-500/10 text-yellow-500 cursor-pointer"
}
onClick={() => handleValidateDomain(domain.host)}
>
{validationState?.isLoading ? (
<>
<Loader2 className="size-3 mr-1 animate-spin" />
Checking...
</>
) : validationState?.isValid ? (
<>
<CheckCircle2 className="size-3 mr-1" />
{validationState.message && validationState.cdnProvider
? `${validationState.cdnProvider}`
: "Valid"}
</>
) : validationState?.error ? (
<>
<XCircle className="size-3 mr-1" />
Invalid
</>
) : (
<>
<RefreshCw className="size-3 mr-1" />
Validate
</>
)}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{validationState?.error ? (
<div className="flex flex-col gap-1">
<p className="font-medium text-red-500">Error:</p>
<p>{validationState.error}</p>
</div>
) : (
"Click to validate DNS configuration"
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
},
},
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Created
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const createdAt = row.getValue("createdAt") as string;
return (
<div className="text-sm text-muted-foreground">
{new Date(createdAt).toLocaleDateString()}
</div>
);
},
},
{
id: "actions",
header: "Actions",
enableHiding: false,
cell: ({ row }) => {
const domain = row.original;
return (
<div className="flex items-center gap-2">
{!domain.host.includes("traefik.me") && (
<DnsHelperModal
domain={{
host: domain.host,
https: domain.https,
path: domain.path || undefined,
}}
serverIp={serverIp}
/>
)}
{canCreateDomain && (
<AddDomain id={id} type={type} domainId={domain.domainId}>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 h-8 w-8"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</AddDomain>
)}
{canDeleteDomain && (
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await handleDeleteDomain(domain.domainId);
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 h-8 w-8"
isLoading={isDeleting}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
);
},
},
];

View File

@@ -1,12 +1,11 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; 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 Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import z from "zod"; import z from "zod";
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -62,14 +61,11 @@ export const domain = z
.min(1, { message: "Port must be at least 1" }) .min(1, { message: "Port must be at least 1" })
.max(65535, { message: "Port must be 65535 or below" }) .max(65535, { message: "Port must be 65535 or below" })
.optional(), .optional(),
useCustomEntrypoint: z.boolean(),
customEntrypoint: z.string().optional(),
https: z.boolean().optional(), https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(), certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
customCertResolver: z.string().optional(), customCertResolver: z.string().optional(),
serviceName: z.string().optional(), serviceName: z.string().optional(),
domainType: z.enum(["application", "compose", "preview"]).optional(), domainType: z.enum(["application", "compose", "preview"]).optional(),
middlewares: z.array(z.string()).optional(),
}) })
.superRefine((input, ctx) => { .superRefine((input, ctx) => {
if (input.https && !input.certificateType) { if (input.https && !input.certificateType) {
@@ -118,14 +114,6 @@ export const domain = z
message: "Internal path must start with '/'", message: "Internal path must start with '/'",
}); });
} }
if (input.useCustomEntrypoint && !input.customEntrypoint) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["customEntrypoint"],
message: "Custom entry point must be specified",
});
}
}); });
type Domain = z.infer<typeof domain>; type Domain = z.infer<typeof domain>;
@@ -208,20 +196,16 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
internalPath: undefined, internalPath: undefined,
stripPath: false, stripPath: false,
port: undefined, port: undefined,
useCustomEntrypoint: false,
customEntrypoint: undefined,
https: false, https: false,
certificateType: undefined, certificateType: undefined,
customCertResolver: undefined, customCertResolver: undefined,
serviceName: undefined, serviceName: undefined,
domainType: type, domainType: type,
middlewares: [],
}, },
mode: "onChange", mode: "onChange",
}); });
const certificateType = form.watch("certificateType"); const certificateType = form.watch("certificateType");
const useCustomEntrypoint = form.watch("useCustomEntrypoint");
const https = form.watch("https"); const https = form.watch("https");
const domainType = form.watch("domainType"); const domainType = form.watch("domainType");
const host = form.watch("host"); const host = form.watch("host");
@@ -236,13 +220,10 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
internalPath: data?.internalPath || undefined, internalPath: data?.internalPath || undefined,
stripPath: data?.stripPath || false, stripPath: data?.stripPath || false,
port: data?.port || undefined, port: data?.port || undefined,
useCustomEntrypoint: !!data.customEntrypoint,
customEntrypoint: data.customEntrypoint || undefined,
certificateType: data?.certificateType || undefined, certificateType: data?.certificateType || undefined,
customCertResolver: data?.customCertResolver || undefined, customCertResolver: data?.customCertResolver || undefined,
serviceName: data?.serviceName || undefined, serviceName: data?.serviceName || undefined,
domainType: data?.domainType || type, domainType: data?.domainType || type,
middlewares: data?.middlewares || [],
}); });
} }
@@ -253,13 +234,10 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
internalPath: undefined, internalPath: undefined,
stripPath: false, stripPath: false,
port: undefined, port: undefined,
useCustomEntrypoint: false,
customEntrypoint: undefined,
https: false, https: false,
certificateType: undefined, certificateType: undefined,
customCertResolver: undefined, customCertResolver: undefined,
domainType: type, domainType: type,
middlewares: [],
}); });
} }
}, [form, data, isPending, domainId]); }, [form, data, isPending, domainId]);
@@ -290,7 +268,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
composeId: id, composeId: id,
}), }),
...data, ...data,
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
}) })
.then(async () => { .then(async () => {
toast.success(dictionary.success); toast.success(dictionary.success);
@@ -658,55 +635,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
}} }}
/> />
<FormField
control={form.control}
name="useCustomEntrypoint"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>Custom Entrypoint</FormLabel>
<FormDescription>
Use custom entrypoint for domina
<br />
"web" and/or "websecure" is used by default.
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {
form.setValue("customEntrypoint", undefined);
}
}}
/>
</FormControl>
</FormItem>
)}
/>
{useCustomEntrypoint && (
<FormField
control={form.control}
name="customEntrypoint"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Entrypoint Name</FormLabel>
<FormControl>
<Input
placeholder="Enter entrypoint name manually"
{...field}
className="w-full"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField <FormField
control={form.control} control={form.control}
name="https" name="https"
@@ -797,88 +725,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
)} )}
</> </>
)} )}
<FormField
control={form.control}
name="middlewares"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>Middlewares</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p>
Add Traefik middleware references. Middlewares
must be defined in your Traefik configuration.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((name, index) => (
<Badge key={index} variant="secondary">
{name}
<X
className="ml-1 size-3 cursor-pointer"
onClick={() => {
const newMiddlewares = [...(field.value || [])];
newMiddlewares.splice(index, 1);
form.setValue("middlewares", newMiddlewares);
}}
/>
</Badge>
))}
</div>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="e.g., rate-limit@file, auth@file"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const value = input.value.trim();
if (value && !field.value?.includes(value)) {
form.setValue("middlewares", [
...(field.value || []),
value,
]);
input.value = "";
}
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="e.g., rate-limit@file, auth@file"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value && !field.value?.includes(value)) {
form.setValue("middlewares", [
...(field.value || []),
value,
]);
input.value = "";
}
}}
>
Add
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
</div> </div>
</form> </form>

View File

@@ -1,22 +1,8 @@
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import { import {
CheckCircle2, CheckCircle2,
ChevronDown,
ExternalLink, ExternalLink,
GlobeIcon, GlobeIcon,
InfoIcon, InfoIcon,
LayoutGrid,
LayoutList,
Loader2, Loader2,
PenBoxIcon, PenBoxIcon,
RefreshCw, RefreshCw,
@@ -37,21 +23,6 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -59,7 +30,6 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { createColumns } from "./columns";
import { DnsHelperModal } from "./dns-helper-modal"; import { DnsHelperModal } from "./dns-helper-modal";
import { AddDomain } from "./handle-domain"; import { AddDomain } from "./handle-domain";
@@ -80,9 +50,6 @@ interface Props {
} }
export const ShowDomains = ({ id, type }: Props) => { export const ShowDomains = ({ id, type }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canCreateDomain = permissions?.domain.create ?? false;
const canDeleteDomain = permissions?.domain.delete ?? false;
const { data: application } = const { data: application } =
type === "application" type === "application"
? api.application.one.useQuery( ? api.application.one.useQuery(
@@ -104,19 +71,6 @@ export const ShowDomains = ({ id, type }: Props) => {
const [validationStates, setValidationStates] = useState<ValidationStates>( const [validationStates, setValidationStates] = useState<ValidationStates>(
{}, {},
); );
const [viewMode, setViewMode] = useState<"grid" | "table">(() => {
if (typeof window !== "undefined") {
return (
(localStorage.getItem("domains-view-mode") as "grid" | "table") ??
"grid"
);
}
return "grid";
});
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
const { data: ip } = api.settings.getIp.useQuery(); const { data: ip } = api.settings.getIp.useQuery();
const { const {
@@ -146,16 +100,6 @@ export const ShowDomains = ({ id, type }: Props) => {
const { mutateAsync: deleteDomain, isPending: isRemoving } = const { mutateAsync: deleteDomain, isPending: isRemoving } =
api.domain.delete.useMutation(); api.domain.delete.useMutation();
const handleDeleteDomain = async (domainId: string) => {
try {
await deleteDomain({ domainId });
refetch();
toast.success("Domain deleted successfully");
} catch {
toast.error("Error deleting domain");
}
};
const handleValidateDomain = async (host: string) => { const handleValidateDomain = async (host: string) => {
setValidationStates((prev) => ({ setValidationStates((prev) => ({
...prev, ...prev,
@@ -193,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 ( return (
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
<Card className="bg-background"> <Card className="bg-background">
@@ -235,32 +148,13 @@ export const ShowDomains = ({ id, type }: Props) => {
</CardDescription> </CardDescription>
</div> </div>
<div className="flex flex-row gap-2 flex-wrap"> <div className="flex flex-row gap-4 flex-wrap">
{data && data?.length > 0 && ( {data && data?.length > 0 && (
<> <AddDomain id={id} type={type}>
<Button <Button>
variant="outline" <GlobeIcon className="size-4" /> Add Domain
size="icon"
onClick={() => {
const next = viewMode === "grid" ? "table" : "grid";
localStorage.setItem("domains-view-mode", next);
setViewMode(next);
}}
>
{viewMode === "grid" ? (
<LayoutList className="size-4" />
) : (
<LayoutGrid className="size-4" />
)}
</Button> </Button>
{canCreateDomain && ( </AddDomain>
<AddDomain id={id} type={type}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
</AddDomain>
)}
</>
)} )}
</div> </div>
</CardHeader> </CardHeader>
@@ -279,131 +173,13 @@ export const ShowDomains = ({ id, type }: Props) => {
To access the application it is required to set at least 1 To access the application it is required to set at least 1
domain domain
</span> </span>
{canCreateDomain && ( <div className="flex flex-row gap-4 flex-wrap">
<div className="flex flex-row gap-4 flex-wrap"> <AddDomain id={id} type={type}>
<AddDomain id={id} type={type}> <Button>
<Button> <GlobeIcon className="size-4" /> Add Domain
<GlobeIcon className="size-4" /> Add Domain </Button>
</Button> </AddDomain>
</AddDomain>
</div>
)}
</div>
) : viewMode === "table" ? (
<div className="flex flex-col gap-4 w-full">
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by host..."
value={
(table.getColumn("host")?.getFilterValue() as string) ?? ""
}
onChange={(event) =>
table.getColumn("host")?.setFilterValue(event.target.value)
}
className="md:max-w-sm"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="sm:ml-auto max-sm:w-full"
>
Columns <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table?.getRowModel()?.rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{data && data?.length > 0 && (
<div className="flex items-center justify-end space-x-2 py-4">
<div className="space-x-2 flex flex-wrap">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
)}
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] "> <div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
@@ -438,51 +214,47 @@ export const ShowDomains = ({ id, type }: Props) => {
} }
/> />
)} )}
{canCreateDomain && ( <AddDomain
<AddDomain id={id}
id={id} type={type}
type={type} domainId={item.domainId}
domainId={item.domainId} >
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
> >
<Button <PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
variant="ghost" </Button>
size="icon" </AddDomain>
className="group hover:bg-blue-500/10" <DialogAction
> title="Delete Domain"
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" /> description="Are you sure you want to delete this domain?"
</Button> type="destructive"
</AddDomain> onClick={async () => {
)} await deleteDomain({
{canDeleteDomain && ( domainId: item.domainId,
<DialogAction })
title="Delete Domain" .then((_data) => {
description="Are you sure you want to delete this domain?" refetch();
type="destructive" toast.success(
onClick={async () => { "Domain deleted successfully",
await deleteDomain({ );
domainId: item.domainId,
}) })
.then((_data) => { .catch(() => {
refetch(); toast.error("Error deleting domain");
toast.success( });
"Domain deleted successfully", }}
); >
}) <Button
.catch(() => { variant="ghost"
toast.error("Error deleting domain"); size="icon"
}); className="group hover:bg-red-500/10"
}} isLoading={isRemoving}
> >
<Button <Trash2 className="size-4 text-primary group-hover:text-red-500" />
variant="ghost" </Button>
size="icon" </DialogAction>
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div> </div>
</div> </div>
<div className="w-full break-all"> <div className="w-full break-all">
@@ -560,22 +332,6 @@ export const ShowDomains = ({ id, type }: Props) => {
</TooltipProvider> </TooltipProvider>
)} )}
{item.middlewares?.map((middleware, index) => (
<TooltipProvider key={`${middleware}-${index}`}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary">
<InfoIcon className="size-3 mr-1" />
Middleware: {middleware}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Traefik middleware reference</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>

View File

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

View File

@@ -31,8 +31,6 @@ interface Props {
} }
export const ShowEnvironment = ({ applicationId }: Props) => { export const ShowEnvironment = ({ applicationId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canWrite = permissions?.envVars.write ?? false;
const { mutateAsync, isPending } = const { mutateAsync, isPending } =
api.application.saveEnvironment.useMutation(); api.application.saveEnvironment.useMutation();
@@ -106,7 +104,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S // Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) { if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
e.preventDefault(); e.preventDefault();
form.handleSubmit(onSubmit)(); form.handleSubmit(onSubmit)();
} }
@@ -203,30 +201,27 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
<Switch <Switch
checked={field.value} checked={field.value}
onCheckedChange={field.onChange} onCheckedChange={field.onChange}
disabled={!canWrite}
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
/> />
)} )}
{canWrite && ( <div className="flex flex-row justify-end gap-2">
<div className="flex flex-row justify-end gap-2"> {hasChanges && (
{hasChanges && ( <Button type="button" variant="outline" onClick={handleCancel}>
<Button type="button" variant="outline" onClick={handleCancel}> Cancel
Cancel
</Button>
)}
<Button
isLoading={isPending}
className="w-fit"
type="submit"
disabled={!hasChanges}
>
Save
</Button> </Button>
</div> )}
)} <Button
isLoading={isPending}
className="w-fit"
type="submit"
disabled={!hasChanges}
>
Save
</Button>
</div>
</form> </form>
</Form> </Form>
</Card> </Card>

View File

@@ -1,5 +1,5 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; 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 Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -416,8 +416,10 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<FormLabel>Watch Paths</FormLabel> <FormLabel>Watch Paths</FormLabel>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" /> <div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p> <p>

View File

@@ -1,5 +1,5 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; 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 Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -228,8 +228,10 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
<FormLabel>Watch Paths</FormLabel> <FormLabel>Watch Paths</FormLabel>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" /> <div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="max-w-[300px]"> <TooltipContent className="max-w-[300px]">
<p> <p>

View File

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

View File

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

View File

@@ -91,7 +91,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
}, [option, services, containers]); }, [option, services, containers]);
const isLoading = option === "native" ? containersLoading : servicesLoading; const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLength = const containersLenght =
option === "native" ? containers?.length : services?.length; option === "native" ? containers?.length : services?.length;
return ( return (
@@ -167,7 +167,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
</> </>
)} )}
<SelectLabel>Containers ({containersLength})</SelectLabel> <SelectLabel>Containers ({containersLenght})</SelectLabel>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>

View File

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

View File

@@ -71,7 +71,6 @@ const formSchema = z
"mongo", "mongo",
"mysql", "mysql",
"redis", "redis",
"libsql",
]), ]),
serviceName: z.string(), serviceName: z.string(),
destinationId: z.string().min(1, "Destination required"), destinationId: z.string().min(1, "Destination required"),
@@ -483,7 +482,7 @@ export const HandleVolumeBackups = ({
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription> <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 volume here, you can type the volume name manually
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
@@ -518,7 +517,7 @@ export const HandleVolumeBackups = ({
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription> <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 here, you can type the volume name manually
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />

View File

@@ -46,8 +46,6 @@ interface Props {
} }
export const DeleteService = ({ id, type }: Props) => { export const DeleteService = ({ id, type }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canDelete = permissions?.service.delete ?? false;
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const queryMap = { const queryMap = {
@@ -57,7 +55,6 @@ export const DeleteService = ({ id, type }: Props) => {
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () => mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
application: () => application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
@@ -73,7 +70,6 @@ export const DeleteService = ({ id, type }: Props) => {
redis: () => api.redis.remove.useMutation(), redis: () => api.redis.remove.useMutation(),
mysql: () => api.mysql.remove.useMutation(), mysql: () => api.mysql.remove.useMutation(),
mariadb: () => api.mariadb.remove.useMutation(), mariadb: () => api.mariadb.remove.useMutation(),
libsql: () => api.libsql.remove.useMutation(),
application: () => api.application.delete.useMutation(), application: () => api.application.delete.useMutation(),
mongo: () => api.mongo.remove.useMutation(), mongo: () => api.mongo.remove.useMutation(),
compose: () => api.compose.delete.useMutation(), compose: () => api.compose.delete.useMutation(),
@@ -100,7 +96,6 @@ export const DeleteService = ({ id, type }: Props) => {
redisId: id || "", redisId: id || "",
mysqlId: id || "", mysqlId: id || "",
mariadbId: id || "", mariadbId: id || "",
libsqlId: id || "",
applicationId: id || "", applicationId: id || "",
composeId: id || "", composeId: id || "",
deleteVolumes, deleteVolumes,
@@ -128,8 +123,6 @@ export const DeleteService = ({ id, type }: Props) => {
data?.applicationStatus === "running") || data?.applicationStatus === "running") ||
(data && "composeStatus" in data && data?.composeStatus === "running"); (data && "composeStatus" in data && data?.composeStatus === "running");
if (!canDelete) return null;
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>

View File

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

View File

@@ -26,8 +26,6 @@ const AddComposeFile = z.object({
type AddComposeFile = z.infer<typeof AddComposeFile>; type AddComposeFile = z.infer<typeof AddComposeFile>;
export const ComposeFileEditor = ({ composeId }: Props) => { export const ComposeFileEditor = ({ composeId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canUpdate = permissions?.service.create ?? false;
const utils = api.useUtils(); const utils = api.useUtils();
const { data, refetch } = api.compose.one.useQuery( const { data, refetch } = api.compose.one.useQuery(
{ {
@@ -95,7 +93,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S // Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) { if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
e.preventDefault(); e.preventDefault();
form.handleSubmit(onSubmit)(); form.handleSubmit(onSubmit)();
} }
@@ -166,16 +164,14 @@ services:
</Form> </Form>
<div className="flex justify-between flex-col lg:flex-row gap-2"> <div className="flex justify-between flex-col lg:flex-row gap-2">
<div className="w-full flex flex-col lg:flex-row gap-4 items-end" /> <div className="w-full flex flex-col lg:flex-row gap-4 items-end" />
{canUpdate && ( <Button
<Button type="submit"
type="submit" form="hook-form-save-compose-file"
form="hook-form-save-compose-file" isLoading={isPending}
isLoading={isPending} className="lg:w-fit w-full"
className="lg:w-fit w-full" >
> Save
Save </Button>
</Button>
)}
</div> </div>
</div> </div>
</> </>

View File

@@ -1,5 +1,5 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; 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 Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -230,8 +230,10 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel> <FormLabel>Watch Paths</FormLabel>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" /> <div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="max-w-[300px]"> <TooltipContent className="max-w-[300px]">
<p> <p>

View File

@@ -1,5 +1,5 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, Plus, X, HelpCircle } from "lucide-react"; import { CheckIcon, ChevronsUpDown, Plus, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -409,8 +409,10 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel> <FormLabel>Watch Paths</FormLabel>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" /> <div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p> <p>

View File

@@ -77,7 +77,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
}, [option, services, containers]); }, [option, services, containers]);
const isLoading = option === "native" ? containersLoading : servicesLoading; const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLength = const containersLenght =
option === "native" ? containers?.length : services?.length; option === "native" ? containers?.length : services?.length;
return ( return (
@@ -152,7 +152,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
</> </>
)} )}
<SelectLabel>Containers ({containersLength})</SelectLabel> <SelectLabel>Containers ({containersLenght})</SelectLabel>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>

View File

@@ -65,13 +65,7 @@ import { ScheduleFormField } from "../../application/schedules/handle-schedules"
type CacheType = "cache" | "fetch"; type CacheType = "cache" | "fetch";
type DatabaseType = type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
| "postgres"
| "mariadb"
| "mysql"
| "mongo"
| "web-server"
| "libsql";
const Schema = z const Schema = z
.object({ .object({
@@ -83,7 +77,7 @@ const Schema = z
keepLatestCount: z.coerce.number().optional(), keepLatestCount: z.coerce.number().optional(),
serviceName: z.string().nullable(), serviceName: z.string().nullable(),
databaseType: z databaseType: z
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server", "libsql"]) .enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
.optional(), .optional(),
backupType: z.enum(["database", "compose"]), backupType: z.enum(["database", "compose"]),
metadata: z metadata: z
@@ -215,12 +209,7 @@ export const HandleBackup = ({
const form = useForm({ const form = useForm({
defaultValues: { defaultValues: {
database: database: databaseType === "web-server" ? "dokploy" : "",
databaseType === "web-server"
? "dokploy"
: databaseType === "libsql"
? "iku.db"
: "",
destinationId: "", destinationId: "",
enabled: true, enabled: true,
prefix: "/", prefix: "/",
@@ -257,9 +246,7 @@ export const HandleBackup = ({
? backup?.database ? backup?.database
: databaseType === "web-server" : databaseType === "web-server"
? "dokploy" ? "dokploy"
: databaseType === "libsql" : "",
? "iku.db"
: "",
destinationId: backup?.destinationId ?? "", destinationId: backup?.destinationId ?? "",
enabled: backup?.enabled ?? true, enabled: backup?.enabled ?? true,
prefix: backup?.prefix ?? "/", prefix: backup?.prefix ?? "/",
@@ -294,15 +281,11 @@ export const HandleBackup = ({
? { ? {
mongoId: id, mongoId: id,
} }
: databaseType === "libsql" : databaseType === "web-server"
? { ? {
libsqlId: id, userId: id,
} }
: databaseType === "web-server" : undefined;
? {
userId: id,
}
: undefined;
await createBackup({ await createBackup({
destinationId: data.destinationId, destinationId: data.destinationId,
@@ -585,10 +568,7 @@ export const HandleBackup = ({
<FormLabel>Database</FormLabel> <FormLabel>Database</FormLabel>
<FormControl> <FormControl>
<Input <Input
disabled={ disabled={databaseType === "web-server"}
databaseType === "web-server" ||
databaseType === "libsql"
}
placeholder={"dokploy"} placeholder={"dokploy"}
{...field} {...field}
/> />

View File

@@ -88,7 +88,7 @@ const RestoreBackupSchema = z
message: "Database name is required", message: "Database name is required",
}), }),
databaseType: z databaseType: z
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server", "libsql"]) .enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
.optional(), .optional(),
backupType: z.enum(["database", "compose"]).default("database"), backupType: z.enum(["database", "compose"]).default("database"),
metadata: z metadata: z
@@ -211,12 +211,7 @@ export const RestoreBackup = ({
defaultValues: { defaultValues: {
destinationId: "", destinationId: "",
backupFile: "", backupFile: "",
databaseName: databaseName: databaseType === "web-server" ? "dokploy" : "",
databaseType === "web-server"
? "dokploy"
: databaseType === "libsql"
? "iku.db"
: "",
databaseType: databaseType:
backupType === "compose" ? ("postgres" as DatabaseType) : databaseType, backupType === "compose" ? ("postgres" as DatabaseType) : databaseType,
backupType: backupType, backupType: backupType,
@@ -225,7 +220,7 @@ export const RestoreBackup = ({
resolver: zodResolver(RestoreBackupSchema), resolver: zodResolver(RestoreBackupSchema),
}); });
const destinationId = form.watch("destinationId"); const destionationId = form.watch("destinationId");
const currentDatabaseType = form.watch("databaseType"); const currentDatabaseType = form.watch("databaseType");
const metadata = form.watch("metadata"); const metadata = form.watch("metadata");
@@ -240,12 +235,12 @@ export const RestoreBackup = ({
const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery( const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery(
{ {
destinationId: destinationId, destinationId: destionationId,
search: debouncedSearchTerm, search: debouncedSearchTerm,
serverId: serverId ?? "", serverId: serverId ?? "",
}, },
{ {
enabled: isOpen && !!destinationId, enabled: isOpen && !!destionationId,
}, },
); );
@@ -528,10 +523,7 @@ export const RestoreBackup = ({
<Input <Input
placeholder="Enter database name" placeholder="Enter database name"
{...field} {...field}
disabled={ disabled={databaseType === "web-server"}
databaseType === "web-server" ||
databaseType === "libsql"
}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />

View File

@@ -53,16 +53,14 @@ export const ShowBackups = ({
const queryMap = const queryMap =
backupType === "database" backupType === "database"
? { ? {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
mysql: () =>
api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () => mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () => mongo: () =>
api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), 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(), "web-server": () => api.user.getBackups.useQuery(),
} }
: { : {
@@ -79,11 +77,10 @@ export const ShowBackups = ({
const mutationMap = const mutationMap =
backupType === "database" backupType === "database"
? { ? {
postgres: api.backup.manualBackupPostgres.useMutation(),
mysql: api.backup.manualBackupMySql.useMutation(),
mariadb: api.backup.manualBackupMariadb.useMutation(), mariadb: api.backup.manualBackupMariadb.useMutation(),
mongo: api.backup.manualBackupMongo.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(), "web-server": api.backup.manualBackupWebServer.useMutation(),
} }
: { : {

View File

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

View File

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

View File

@@ -103,7 +103,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
> >
{" "} {" "}
<div className="flex items-start gap-x-2"> <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" /> */} {/* <Square className="size-4 text-muted-foreground opacity-0 group-hover/logitem:opacity-100 transition-opacity" /> */}
{tooltip(color, rawTimestamp)} {tooltip(color, rawTimestamp)}
{!noTimestamp && ( {!noTimestamp && (

View File

@@ -74,18 +74,6 @@ export function parseLogs(logString: string): LogLine[] {
// Detect log type based on message content // Detect log type based on message content
export const getLogType = (message: string): LogStyle => { 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(); const lowerMessage = message.toLowerCase();
if ( if (

View File

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

View File

@@ -10,9 +10,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { ShowContainerConfig } from "../config/show-container-config"; import { ShowContainerConfig } from "../config/show-container-config";
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs"; import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
import { RemoveContainerDialog } from "../remove/remove-container";
import { DockerTerminalModal } from "../terminal/docker-terminal-modal"; import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
import { UploadFileModal } from "../upload/upload-file-modal";
import type { Container } from "./show-containers"; import type { Container } from "./show-containers";
export const columns: ColumnDef<Container>[] = [ export const columns: ColumnDef<Container>[] = [
@@ -129,16 +127,6 @@ export const columns: ColumnDef<Container>[] = [
> >
Terminal Terminal
</DockerTerminalModal> </DockerTerminalModal>
<UploadFileModal
containerId={container.containerId}
serverId={container.serverId || undefined}
>
Upload File
</UploadFileModal>
<RemoveContainerDialog
containerId={container.containerId}
serverId={container.serverId ?? undefined}
/>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );

View File

@@ -35,7 +35,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { api, type RouterOutputs } from "@/utils/api"; import { api, type RouterOutputs } from "@/utils/api";
import { columns } from "./columns"; import { columns } from "./colums";
export type Container = NonNullable< export type Container = NonNullable<
RouterOutputs["docker"]["getContainers"] RouterOutputs["docker"]["getContainers"]
>[0]; >[0];

View File

@@ -1,187 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Upload } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Dropzone } from "@/components/ui/dropzone";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import {
uploadFileToContainerSchema,
type UploadFileToContainer,
} from "@/utils/schema";
interface Props {
containerId: string;
serverId?: string;
children?: React.ReactNode;
}
export const UploadFileModal = ({ children, containerId, serverId }: Props) => {
const [open, setOpen] = useState(false);
const { mutateAsync: uploadFile, isPending: isLoading } =
api.docker.uploadFileToContainer.useMutation({
onSuccess: () => {
toast.success("File uploaded successfully");
setOpen(false);
form.reset();
},
onError: (error) => {
toast.error(error.message || "Failed to upload file to container");
},
});
const form = useForm({
resolver: zodResolver(uploadFileToContainerSchema),
defaultValues: {
containerId,
destinationPath: "/",
serverId: serverId || undefined,
},
});
const file = form.watch("file");
const onSubmit = async (values: UploadFileToContainer) => {
if (!values.file) {
toast.error("Please select a file to upload");
return;
}
const formData = new FormData();
formData.append("containerId", values.containerId);
formData.append("file", values.file);
formData.append("destinationPath", values.destinationPath);
if (values.serverId) {
formData.append("serverId", values.serverId);
}
await uploadFile(formData);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
{children}
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Upload File to Container
</DialogTitle>
<DialogDescription>
Upload a file directly into the container's filesystem
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="destinationPath"
render={({ field }) => (
<FormItem>
<FormLabel>Destination Path</FormLabel>
<FormControl>
<Input
{...field}
placeholder="/path/to/file"
className="font-mono"
/>
</FormControl>
<FormMessage />
<p className="text-xs text-muted-foreground">
Enter the full path where the file should be uploaded in the
container (e.g., /app/config.json)
</p>
</FormItem>
)}
/>
<FormField
control={form.control}
name="file"
render={({ field }) => (
<FormItem>
<FormLabel>File</FormLabel>
<FormControl>
<Dropzone
{...field}
dropMessage="Drop file here or click to browse"
onChange={(files) => {
if (files && files.length > 0) {
field.onChange(files[0]);
} else {
field.onChange(null);
}
}}
/>
</FormControl>
<FormMessage />
{file instanceof File && (
<div className="flex items-center gap-2 p-2 bg-muted rounded-md">
<span className="text-sm text-muted-foreground flex-1">
{file.name} ({(file.size / 1024).toFixed(2)} KB)
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => field.onChange(null)}
>
Remove
</Button>
</div>
)}
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
isLoading={isLoading}
disabled={!file || isLoading}
>
Upload File
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -45,12 +45,10 @@ import {
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
type User = typeof authClient.$Infer.Session.user; type User = typeof authClient.$Infer.Session.user;
export const ImpersonationBar = () => { export const ImpersonationBar = () => {
const { config: whitelabeling } = useWhitelabeling();
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [selectedUser, setSelectedUser] = useState<User | null>(null); const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [isImpersonating, setIsImpersonating] = useState(false); const [isImpersonating, setIsImpersonating] = useState(false);
@@ -182,10 +180,7 @@ export const ImpersonationBar = () => {
)} )}
> >
<div className="flex items-center gap-4 px-4 md:px-20 w-full"> <div className="flex items-center gap-4 px-4 md:px-20 w-full">
<Logo <Logo className="w-10 h-10" />
className="w-10 h-10"
logoUrl={whitelabeling?.logoUrl || undefined}
/>
{!isImpersonating ? ( {!isImpersonating ? (
<div className="flex items-center gap-2 w-full"> <div className="flex items-center gap-2 w-full">
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>

View File

@@ -1,251 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => {
if (a === null || a === undefined || a === "") return null;
const parsed = Number.parseInt(String(a), 10);
return Number.isNaN(parsed) ? null : parsed;
}, z
.number()
.gte(0, "Range must be 0 - 65535")
.lte(65535, "Range must be 0 - 65535")
.nullable()),
externalGRPCPort: z.preprocess((a) => {
if (a === null || a === undefined || a === "") return null;
const parsed = Number.parseInt(String(a), 10);
return Number.isNaN(parsed) ? null : parsed;
}, z
.number()
.gte(0, "Range must be 0 - 65535")
.lte(65535, "Range must be 0 - 65535")
.nullable()),
externalAdminPort: z.preprocess((a) => {
if (a === null || a === undefined || a === "") return null;
const parsed = Number.parseInt(String(a), 10);
return Number.isNaN(parsed) ? null : parsed;
}, z
.number()
.gte(0, "Range must be 0 - 65535")
.lte(65535, "Range must be 0 - 65535")
.nullable()),
});
type DockerProvider = z.infer<typeof DockerProviderSchema>;
interface Props {
libsqlId: string;
}
export const ShowExternalLibsqlCredentials = ({ libsqlId }: Props) => {
const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.libsql.one.useQuery({ libsqlId });
const { mutateAsync, isPending } = api.libsql.saveExternalPorts.useMutation();
const [connectionUrl, setConnectionUrl] = useState("");
const [connectionGRPCUrl, setGRPCConnectionUrl] = useState("");
const getIp = data?.server?.ipAddress || ip;
const form = useForm({
defaultValues: {},
resolver: zodResolver(DockerProviderSchema),
});
useEffect(() => {
if (data) {
form.reset({
externalPort: data.externalPort,
externalGRPCPort: data.externalGRPCPort,
externalAdminPort: data.externalAdminPort,
});
}
}, [form.reset, data, form]);
const onSubmit = async (values: DockerProvider) => {
await mutateAsync({
externalPort: values.externalPort,
externalGRPCPort: values.externalGRPCPort,
externalAdminPort: values.externalAdminPort,
libsqlId,
})
.then(async () => {
toast.success("External port/ports updated");
await refetch();
})
.catch((error: Error) => {
toast.error(error?.message || "Error saving the external port/ports");
});
};
useEffect(() => {
const port = form.watch("externalPort") || data?.externalPort;
setConnectionUrl(
`http://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`,
);
if (data?.sqldNode !== "replica") {
const grpcPort = form.watch("externalGRPCPort") || data?.externalGRPCPort;
setGRPCConnectionUrl(
`http://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${grpcPort}`,
);
}
}, [
data?.externalGRPCPort,
data?.databasePassword,
form,
data?.databaseUser,
getIp,
]);
return (
<div className="flex w-full flex-col gap-5">
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription>
In order to make the database reachable through the internet, you
must set a port and ensure that the port is not being used by
another application or database
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">
{!getIp && (
<AlertBlock type="warning">
You need to set an IP address in your{" "}
<Link href="/dashboard/settings/server" className="text-primary">
{data?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"}
</Link>{" "}
to fix the database url connection.
</AlertBlock>
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 space-y-4">
<FormField
control={form.control}
name="externalPort"
render={({ field }) => (
<FormItem>
<FormLabel>External Port (Internet)</FormLabel>
<FormControl>
<Input
placeholder="8080"
{...field}
value={field.value as string}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{!!data?.externalPort && (
<div className="grid w-full gap-8">
<div className="flex flex-col gap-3">
<Label>External Host</Label>
<ToggleVisibilityInput value={connectionUrl} disabled />
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 space-y-4">
<FormField
control={form.control}
name="externalAdminPort"
render={({ field }) => (
<FormItem>
<FormLabel>External Admin Port (Internet)</FormLabel>
<FormControl>
<Input
placeholder="5000"
{...field}
value={field.value as string}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{data?.sqldNode !== "replica" && (
<>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 space-y-4">
<FormField
control={form.control}
name="externalGRPCPort"
render={({ field }) => (
<FormItem>
<FormLabel>External GRPC Port (Internet)</FormLabel>
<FormControl>
<Input
placeholder="5001"
{...field}
value={field.value as string}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{!!data?.externalGRPCPort && (
<div className="grid w-full gap-8">
<div className="flex flex-col gap-3">
<Label>External GRPC Host</Label>
<ToggleVisibilityInput
value={connectionGRPCUrl}
disabled
/>
</div>
</div>
)}
</>
)}
<div className="flex justify-end">
<Button type="submit" isLoading={isPending}>
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
);
};

View File

@@ -1,268 +0,0 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { type LogLine, parseLogs } from "../../docker/logs/utils";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
interface Props {
libsqlId: string;
}
export const ShowGeneralLibsql = ({ libsqlId }: Props) => {
const { data, refetch } = api.libsql.one.useQuery(
{
libsqlId,
},
{ enabled: !!libsqlId },
);
const { mutateAsync: reload, isPending: isReloading } =
api.libsql.reload.useMutation();
const { mutateAsync: start, isPending: isStarting } =
api.libsql.start.useMutation();
const { mutateAsync: stop, isPending: isStopping } =
api.libsql.stop.useMutation();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [isDeploying, setIsDeploying] = useState(false);
api.libsql.deployWithLogs.useSubscription(
{
libsqlId: libsqlId,
},
{
enabled: isDeploying,
onData(log) {
if (!isDrawerOpen) {
setIsDrawerOpen(true);
}
if (log === "Deployment completed successfully!") {
setIsDeploying(false);
}
const parsedLogs = parseLogs(log);
setFilteredLogs((prev) => [...prev, ...parsedLogs]);
},
onError(error) {
console.error("Deployment logs error:", error);
setIsDeploying(false);
},
},
);
return (
<>
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}>
<DialogAction
title="Deploy Libsql"
description="Are you sure you want to deploy this Libsql?"
type="default"
onClick={async () => {
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads and sets up the Libsql database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<DialogAction
title="Reload Libsql"
description="Are you sure you want to reload this libsql?"
type="default"
onClick={async () => {
await reload({
libsqlId: libsqlId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Libsql reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading Libsql");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the Libsql service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
</TooltipProvider>
{data?.applicationStatus === "idle" ? (
<TooltipProvider delayDuration={0}>
<DialogAction
title="Start Libsql"
description="Are you sure you want to start this Libsql?"
type="default"
onClick={async () => {
await start({
libsqlId: libsqlId,
})
.then(() => {
toast.success("Libsql started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Libsql");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the Libsql database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
</TooltipProvider>
) : (
<TooltipProvider delayDuration={0}>
<DialogAction
title="Stop Libsql"
description="Are you sure you want to stop this Libsql?"
onClick={async () => {
await stop({
libsqlId: libsqlId,
})
.then(() => {
toast.success("Libsql stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Libsql");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running Libsql database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
</TooltipProvider>
)}
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}
>
<Button
variant="outline"
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Terminal className="size-4 mr-1" />
Open Terminal
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Open a terminal to the Libsql container</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DockerTerminalModal>
</CardContent>
</Card>
<DrawerLogs
isOpen={isDrawerOpen}
onClose={() => {
setIsDrawerOpen(false);
setFilteredLogs([]);
setIsDeploying(false);
refetch();
}}
filteredLogs={filteredLogs}
/>
</div>
</>
);
};

View File

@@ -1,121 +0,0 @@
import { SelectGroup } from "@radix-ui/react-select";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
interface Props {
libsqlId: string;
}
export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => {
const { data } = api.libsql.one.useQuery({ libsqlId });
return (
<>
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Internal Credentials</CardTitle>
</CardHeader>
<CardContent className="flex w-full flex-row gap-4">
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2">
<Label>User</Label>
<Input disabled value={data?.databaseUser} />
</div>
<div className="flex flex-col gap-2">
<Label>Sqld Node</Label>
<Select value={data?.sqldNode} disabled>
<SelectTrigger>
<SelectValue placeholder="Select Node type" />
</SelectTrigger>
<SelectContent>
{["primary", "replica"].map((node) => (
<SelectItem key={node} value={node}>
{node.charAt(0).toUpperCase() + node.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<ToggleVisibilityInput
disabled
value={data?.databasePassword}
/>
</div>
</div>
<div className="flex flex-row gap-2">
<div className="w-full flex flex-col gap-2">
<Label>Internal Port (Container)</Label>
<Input disabled value="8080" />
</div>
<div className="w-full flex flex-col gap-2">
<Label>Internal GRPC Port (Container)</Label>
<Input disabled value="5001" />
</div>
<div className="w-full flex flex-col gap-2">
<Label>Internal Admin Port (Container)</Label>
<Input disabled value="5000" />
</div>
</div>
<div className="flex flex-col gap-2">
<Label>Internal Host</Label>
<Input disabled value={data?.appName} />
</div>
<div className="flex flex-col gap-2">
<Label>Enable Namespaces</Label>
<Select
disabled
defaultValue={
data?.enableNamespaces
? String(data?.enableNamespaces)
: "false"
}
>
<SelectTrigger>
<SelectValue placeholder={"false"} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{["false", "true"].map((node) => (
<SelectItem key={node} value={node}>
{node.charAt(0).toUpperCase() + node.slice(1)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2 md:col-span-2">
<Label>Internal Connection URL </Label>
<ToggleVisibilityInput
disabled
value={`http://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:8080`}
/>
</div>
<div className="flex flex-col gap-2 md:col-span-2">
<Label>Internal Replication Connection URL </Label>
<ToggleVisibilityInput
disabled
value={`http://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:5001`}
/>
</div>
</div>
</CardContent>
</Card>
</div>
</>
);
};

View File

@@ -1,163 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
const updateLibsqlSchema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
description: z.string().optional(),
});
type UpdateLibsql = z.infer<typeof updateLibsqlSchema>;
interface Props {
libsqlId: string;
}
export const UpdateLibsql = ({ libsqlId }: Props) => {
const utils = api.useUtils();
const { mutateAsync, error, isError, isPending } =
api.libsql.update.useMutation();
const { data } = api.libsql.one.useQuery(
{
libsqlId,
},
{
enabled: !!libsqlId,
},
);
const form = useForm<UpdateLibsql>({
defaultValues: {
description: data?.description ?? "",
name: data?.name ?? "",
},
resolver: zodResolver(updateLibsqlSchema),
});
useEffect(() => {
if (data) {
form.reset({
description: data.description ?? "",
name: data.name,
});
}
}, [data, form, form.reset]);
const onSubmit = async (formData: UpdateLibsql) => {
await mutateAsync({
name: formData.name,
libsqlId: libsqlId,
description: formData.description || "",
})
.then(() => {
toast.success("Libsql updated successfully");
utils.libsql.one.invalidate({
libsqlId: libsqlId,
});
})
.catch(() => {
toast.error("Error updating the Libsql");
})
.finally(() => {});
};
return (
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Modify Libsql</DialogTitle>
<DialogDescription>Update the Libsql data</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="grid gap-4">
<div className="grid items-center gap-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-update-libsql"
className="grid w-full gap-4 "
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Vandelay Industries" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Description about your project..."
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
isLoading={isPending}
form="hook-form-update-libsql"
type="submit"
>
Update
</Button>
</DialogFooter>
</form>
</Form>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -21,8 +21,6 @@ interface Props {
} }
export const ShowGeneralMariadb = ({ mariadbId }: Props) => { export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canDeploy = permissions?.deployment.create ?? false;
const { data, refetch } = api.mariadb.one.useQuery( const { data, refetch } = api.mariadb.one.useQuery(
{ {
mariadbId, mariadbId,
@@ -74,75 +72,154 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
<CardTitle className="text-xl">Deploy Settings</CardTitle> <CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap"> <CardContent className="flex flex-row gap-4 flex-wrap">
{canDeploy && ( <TooltipProvider delayDuration={0}>
<TooltipProvider delayDuration={0}> <DialogAction
<DialogAction title="Deploy Mariadb"
title="Deploy Mariadb" description="Are you sure you want to deploy this mariadb?"
description="Are you sure you want to deploy this mariadb?" type="default"
type="default" onClick={async () => {
onClick={async () => { setIsDeploying(true);
setIsDeploying(true); await new Promise((resolve) => setTimeout(resolve, 1000));
await new Promise((resolve) => setTimeout(resolve, 1000)); refetch();
refetch(); }}
}} >
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Button <Tooltip>
variant="default" <TooltipTrigger asChild>
isLoading={data?.applicationStatus === "running"} <div className="flex items-center">
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" <Rocket className="size-4 mr-1" />
> Deploy
<Tooltip> </div>
<TooltipTrigger asChild> </TooltipTrigger>
<div className="flex items-center"> <TooltipPrimitive.Portal>
<Rocket className="size-4 mr-1" /> <TooltipContent sideOffset={5} className="z-[60]">
Deploy <p>Downloads and sets up the MariaDB database</p>
</div> </TooltipContent>
</TooltipTrigger> </TooltipPrimitive.Portal>
<TooltipPrimitive.Portal> </Tooltip>
<TooltipContent sideOffset={5} className="z-[60]"> </Button>
<p>Downloads and sets up the MariaDB database</p> </DialogAction>
</TooltipContent> </TooltipProvider>
</TooltipPrimitive.Portal> <TooltipProvider delayDuration={0}>
</Tooltip> <DialogAction
</Button> title="Reload Mariadb"
</DialogAction> description="Are you sure you want to reload this mariadb?"
</TooltipProvider> type="default"
)} onClick={async () => {
{canDeploy && ( await reload({
mariadbId: mariadbId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Mariadb reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading Mariadb");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MariaDB service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
</TooltipProvider>
{data?.applicationStatus === "idle" ? (
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<DialogAction <DialogAction
title="Reload Mariadb" title="Start Mariadb"
description="Are you sure you want to reload this mariadb?" description="Are you sure you want to start this mariadb?"
type="default" type="default"
onClick={async () => { onClick={async () => {
await reload({ await start({
mariadbId: mariadbId, mariadbId: mariadbId,
appName: data?.appName || "",
}) })
.then(() => { .then(() => {
toast.success("Mariadb reloaded successfully"); toast.success("Mariadb started successfully");
refetch(); refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error reloading Mariadb"); toast.error("Error starting Mariadb");
}); });
}} }}
> >
<Button <Button
variant="secondary" variant="secondary"
isLoading={isReloading} isLoading={isStarting}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center"> <div className="flex items-center">
<RefreshCcw className="size-4 mr-1" /> <CheckCircle2 className="size-4 mr-1" />
Reload Start
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MariaDB service without rebuilding</p> <p>
Start the MariaDB database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
</TooltipProvider>
) : (
<TooltipProvider delayDuration={0}>
<DialogAction
title="Stop Mariadb"
description="Are you sure you want to stop this mariadb?"
onClick={async () => {
await stop({
mariadbId: mariadbId,
})
.then(() => {
toast.success("Mariadb stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Mariadb");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running MariaDB database</p>
</TooltipContent> </TooltipContent>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
</Tooltip> </Tooltip>
@@ -150,90 +227,6 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
</DialogAction> </DialogAction>
</TooltipProvider> </TooltipProvider>
)} )}
{canDeploy &&
(data?.applicationStatus === "idle" ? (
<TooltipProvider delayDuration={0}>
<DialogAction
title="Start Mariadb"
description="Are you sure you want to start this mariadb?"
type="default"
onClick={async () => {
await start({
mariadbId: mariadbId,
})
.then(() => {
toast.success("Mariadb started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Mariadb");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the MariaDB database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
</TooltipProvider>
) : (
<TooltipProvider delayDuration={0}>
<DialogAction
title="Stop Mariadb"
description="Are you sure you want to stop this mariadb?"
onClick={async () => {
await stop({
mariadbId: mariadbId,
})
.then(() => {
toast.success("Mariadb stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Mariadb");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running MariaDB database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
</TooltipProvider>
))}
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}
serverId={data?.serverId || ""} serverId={data?.serverId || ""}

View File

@@ -1,19 +1,14 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input"; import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props { interface Props {
mariadbId: string; mariadbId: string;
} }
export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => { export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
const { data } = api.mariadb.one.useQuery({ mariadbId }); const { data } = api.mariadb.one.useQuery({ mariadbId });
const utils = api.useUtils();
const { mutateAsync: changePassword } =
api.mariadb.changePassword.useMutation();
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
@@ -33,43 +28,20 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Password</Label> <Label>Password</Label>
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-4">
<ToggleVisibilityInput <ToggleVisibilityInput
disabled disabled
value={data?.databasePassword} value={data?.databasePassword}
/> />
<UpdateDatabasePassword
onUpdatePassword={async (newPassword) => {
await changePassword({
mariadbId,
password: newPassword,
type: "user",
});
toast.success("Password updated successfully");
utils.mariadb.one.invalidate({ mariadbId });
}}
/>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Root Password</Label> <Label>Root Password</Label>
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-4">
<ToggleVisibilityInput <ToggleVisibilityInput
disabled disabled
value={data?.databaseRootPassword} value={data?.databaseRootPassword}
/> />
<UpdateDatabasePassword
label="Root Password"
onUpdatePassword={async (newPassword) => {
await changePassword({
mariadbId,
password: newPassword,
type: "root",
});
toast.success("Root password updated successfully");
utils.mariadb.one.invalidate({ mariadbId });
}}
/>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">

View File

@@ -21,8 +21,6 @@ interface Props {
} }
export const ShowGeneralMongo = ({ mongoId }: Props) => { export const ShowGeneralMongo = ({ mongoId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canDeploy = permissions?.deployment.create ?? false;
const { data, refetch } = api.mongo.one.useQuery( const { data, refetch } = api.mongo.one.useQuery(
{ {
mongoId, mongoId,
@@ -75,158 +73,153 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap"> <CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
{canDeploy && ( <DialogAction
<DialogAction title="Deploy Mongo"
title="Deploy Mongo" description="Are you sure you want to deploy this mongo?"
description="Are you sure you want to deploy this mongo?" type="default"
type="default" onClick={async () => {
onClick={async () => { setIsDeploying(true);
setIsDeploying(true); await new Promise((resolve) => setTimeout(resolve, 1000));
await new Promise((resolve) => setTimeout(resolve, 1000)); refetch();
refetch(); }}
}} >
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Button <Tooltip>
variant="default" <TooltipTrigger asChild>
isLoading={data?.applicationStatus === "running"} <div className="flex items-center">
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" <Rocket className="size-4 mr-1" />
> Deploy
<Tooltip> </div>
<TooltipTrigger asChild> </TooltipTrigger>
<div className="flex items-center"> <TooltipPrimitive.Portal>
<Rocket className="size-4 mr-1" /> <TooltipContent sideOffset={5} className="z-[60]">
Deploy <p>Downloads and sets up the MongoDB database</p>
</div> </TooltipContent>
</TooltipTrigger> </TooltipPrimitive.Portal>
<TooltipPrimitive.Portal> </Tooltip>
<TooltipContent sideOffset={5} className="z-[60]"> </Button>
<p>Downloads and sets up the MongoDB database</p> </DialogAction>
</TooltipContent> <DialogAction
</TooltipPrimitive.Portal> title="Reload Mongo"
</Tooltip> description="Are you sure you want to reload this mongo?"
</Button> type="default"
</DialogAction> onClick={async () => {
)} await reload({
{canDeploy && ( mongoId: mongoId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Mongo reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading Mongo");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MongoDB service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
{data?.applicationStatus === "idle" ? (
<DialogAction <DialogAction
title="Reload Mongo" title="Start Mongo"
description="Are you sure you want to reload this mongo?" description="Are you sure you want to start this mongo?"
type="default" type="default"
onClick={async () => { onClick={async () => {
await reload({ await start({
mongoId: mongoId, mongoId: mongoId,
appName: data?.appName || "",
}) })
.then(() => { .then(() => {
toast.success("Mongo reloaded successfully"); toast.success("Mongo started successfully");
refetch(); refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error reloading Mongo"); toast.error("Error starting Mongo");
}); });
}} }}
> >
<Button <Button
variant="secondary" variant="secondary"
isLoading={isReloading} isLoading={isStarting}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center"> <div className="flex items-center">
<RefreshCcw className="size-4 mr-1" /> <CheckCircle2 className="size-4 mr-1" />
Reload Start
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MongoDB service without rebuilding</p> <p>
Start the MongoDB database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Mongo"
description="Are you sure you want to stop this mongo?"
onClick={async () => {
await stop({
mongoId: mongoId,
})
.then(() => {
toast.success("Mongo stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Mongo");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running MongoDB database</p>
</TooltipContent> </TooltipContent>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
</Tooltip> </Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
)} )}
{canDeploy &&
(data?.applicationStatus === "idle" ? (
<DialogAction
title="Start Mongo"
description="Are you sure you want to start this mongo?"
type="default"
onClick={async () => {
await start({
mongoId: mongoId,
})
.then(() => {
toast.success("Mongo started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Mongo");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the MongoDB database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Mongo"
description="Are you sure you want to stop this mongo?"
onClick={async () => {
await stop({
mongoId: mongoId,
})
.then(() => {
toast.success("Mongo stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Mongo");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running MongoDB database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
))}
</TooltipProvider> </TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}

View File

@@ -1,19 +1,14 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input"; import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props { interface Props {
mongoId: string; mongoId: string;
} }
export const ShowInternalMongoCredentials = ({ mongoId }: Props) => { export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
const { data } = api.mongo.one.useQuery({ mongoId }); const { data } = api.mongo.one.useQuery({ mongoId });
const utils = api.useUtils();
const { mutateAsync: changePassword } =
api.mongo.changePassword.useMutation();
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
@@ -30,21 +25,11 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Password</Label> <Label>Password</Label>
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-4">
<ToggleVisibilityInput <ToggleVisibilityInput
disabled disabled
value={data?.databasePassword} value={data?.databasePassword}
/> />
<UpdateDatabasePassword
onUpdatePassword={async (newPassword) => {
await changePassword({
mongoId,
password: newPassword,
});
toast.success("Password updated successfully");
utils.mongo.one.invalidate({ mongoId });
}}
/>
</div> </div>
</div> </div>

View File

@@ -1,103 +1,103 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import { import {
type ChartConfig, Area,
ChartContainer, AreaChart,
ChartLegend, CartesianGrid,
ChartLegendContent, Legend,
ChartTooltip, ResponsiveContainer,
ChartTooltipContent, Tooltip,
} from "@/components/ui/chart"; YAxis,
} from "recharts";
import type { DockerStatsJSON } from "./show-free-container-monitoring"; import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props { interface Props {
accumulativeData: DockerStatsJSON["block"]; acummulativeData: DockerStatsJSON["block"];
} }
const chartConfig = { export const DockerBlockChart = ({ acummulativeData }: Props) => {
readMb: { const transformedData = acummulativeData.map((item, index) => {
label: "Read (MB)", return {
color: "hsl(var(--chart-1))", time: item.time,
}, name: `Point ${index + 1}`,
writeMb: { readMb: item.value.readMb,
label: "Write (MB)", writeMb: item.value.writeMb,
color: "hsl(var(--chart-2))", };
}, });
} satisfies ChartConfig;
export const DockerBlockChart = ({ accumulativeData }: Props) => {
const transformedData = accumulativeData.map((item, index) => ({
time: item.time,
name: `Point ${index + 1}`,
readMb: item.value.readMb,
writeMb: item.value.writeMb,
}));
return ( return (
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full"> <div className="mt-6 w-full h-[10rem]">
<AreaChart <ResponsiveContainer>
data={transformedData} <AreaChart
margin={{ top: 10, right: 10, left: 0, bottom: 0 }} data={transformedData}
> margin={{
<defs> top: 10,
<linearGradient id="fillBlockRead" x1="0" y1="0" x2="0" y2="1"> right: 30,
<stop left: 0,
offset="5%" bottom: 0,
stopColor="var(--color-readMb)" }}
stopOpacity={0.8} >
/> <defs>
<stop <linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
offset="95%" <stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
stopColor="var(--color-readMb)" <stop offset="95%" stopColor="#8884d8" stopOpacity={0} />
stopOpacity={0.1} </linearGradient>
/> <linearGradient id="colorWrite" x1="0" y1="0" x2="0" y2="1">
</linearGradient> <stop offset="5%" stopColor="#82ca9d" stopOpacity={0.8} />
<linearGradient id="fillBlockWrite" x1="0" y1="0" x2="0" y2="1"> <stop offset="95%" stopColor="#82ca9d" stopOpacity={0} />
<stop </linearGradient>
offset="5%" </defs>
stopColor="var(--color-writeMb)" <YAxis stroke="#A1A1AA" />
stopOpacity={0.8} <CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
/> {/* @ts-ignore */}
<stop <Tooltip content={<CustomTooltip />} />
offset="95%" <Legend />
stopColor="var(--color-writeMb)" <Area
stopOpacity={0.1} type="monotone"
/> dataKey="readMb"
</linearGradient> stroke="#27272A"
</defs> fillOpacity={1}
<CartesianGrid vertical={false} /> fill="url(#colorUv)"
<YAxis tickLine={false} axisLine={false} /> name="Read Mb"
<ChartTooltip />
cursor={false} <Area
content={ type="monotone"
<ChartTooltipContent dataKey="writeMb"
labelFormatter={(_, payload) => { stroke="#82ca9d"
const time = payload?.[0]?.payload?.time; fillOpacity={1}
return time ? format(new Date(time), "PPpp") : ""; fill="url(#colorWrite)"
}} name="Write Mb"
formatter={(value, name) => { />
const label = name === "readMb" ? "Read" : "Write"; </AreaChart>
return [`${value} MB`, label]; </ResponsiveContainer>
}} </div>
/>
}
/>
<Area
type="monotone"
dataKey="readMb"
stroke="var(--color-readMb)"
fill="url(#fillBlockRead)"
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="writeMb"
stroke="var(--color-writeMb)"
fill="url(#fillBlockWrite)"
strokeWidth={2}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
); );
}; };
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
readMb: number;
writeMb: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0]) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
{payload[0].payload.time && (
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
)}
<p>{`Read ${payload[0].payload.readMb} `}</p>
<p>{`Write: ${payload[0].payload.writeMb} `}</p>
</div>
);
}
return null;
};

View File

@@ -1,81 +1,87 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import { import {
type ChartConfig, Area,
ChartContainer, AreaChart,
ChartLegend, CartesianGrid,
ChartLegendContent, Legend,
ChartTooltip, ResponsiveContainer,
ChartTooltipContent, Tooltip,
} from "@/components/ui/chart"; YAxis,
} from "recharts";
import type { DockerStatsJSON } from "./show-free-container-monitoring"; import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props { interface Props {
accumulativeData: DockerStatsJSON["cpu"]; acummulativeData: DockerStatsJSON["cpu"];
} }
const chartConfig = { export const DockerCpuChart = ({ acummulativeData }: Props) => {
usage: { const transformedData = acummulativeData.map((item, index) => {
label: "CPU Usage", return {
color: "hsl(var(--chart-1))", name: `Point ${index + 1}`,
}, time: item.time,
} satisfies ChartConfig; usage: item.value.toString().split("%")[0],
};
export const DockerCpuChart = ({ accumulativeData }: Props) => { });
const transformedData = accumulativeData.map((item, index) => ({
name: `Point ${index + 1}`,
time: item.time,
usage: item.value.toString().split("%")[0],
}));
return ( return (
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full"> <div className="mt-6 w-full h-[10rem]">
<AreaChart <ResponsiveContainer>
data={transformedData} <AreaChart
margin={{ top: 10, right: 10, left: 0, bottom: 0 }} data={transformedData}
> margin={{
<defs> top: 10,
<linearGradient id="fillCpu" x1="0" y1="0" x2="0" y2="1"> right: 30,
<stop left: 0,
offset="5%" bottom: 0,
stopColor="var(--color-usage)" }}
stopOpacity={0.8} >
/> <defs>
<stop <linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
offset="95%" <stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
stopColor="var(--color-usage)" <stop offset="95%" stopColor="white" stopOpacity={0} />
stopOpacity={0.1} </linearGradient>
/> </defs>
</linearGradient> <YAxis stroke="#A1A1AA" domain={[0, 100]} />
</defs> <CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
<CartesianGrid vertical={false} /> {/* @ts-ignore */}
<YAxis <Tooltip content={<CustomTooltip />} />
tickFormatter={(value) => `${value}%`} <Legend />
domain={[0, 100]} <Area
tickLine={false} type="monotone"
axisLine={false} dataKey="usage"
/> stroke="#27272A"
<ChartTooltip fillOpacity={1}
cursor={false} fill="url(#colorUv)"
content={ />
<ChartTooltipContent </AreaChart>
labelFormatter={(_, payload) => { </ResponsiveContainer>
const time = payload?.[0]?.payload?.time; </div>
return time ? format(new Date(time), "PPpp") : "";
}}
formatter={(value) => [`${value}%`, "CPU Usage"]}
/>
}
/>
<Area
type="monotone"
dataKey="usage"
stroke="var(--color-usage)"
fill="url(#fillCpu)"
strokeWidth={2}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
); );
}; };
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
usage: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0]) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
{payload[0].payload.time && (
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
)}
<p>{`CPU Usage: ${payload[0].payload.usage}%`}</p>
</div>
);
}
return null;
};

View File

@@ -1,82 +1,105 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import { import {
type ChartConfig, Area,
ChartContainer, AreaChart,
ChartTooltip, CartesianGrid,
ChartTooltipContent, Legend,
} from "@/components/ui/chart"; ResponsiveContainer,
Tooltip,
YAxis,
} from "recharts";
import type { DockerStatsJSON } from "./show-free-container-monitoring"; import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props { interface Props {
accumulativeData: DockerStatsJSON["disk"]; acummulativeData: DockerStatsJSON["disk"];
diskTotal: number; diskTotal: number;
} }
const chartConfig = { export const DockerDiskChart = ({ acummulativeData, diskTotal }: Props) => {
usedGb: { const transformedData = acummulativeData.map((item, index) => {
label: "Used (GB)", return {
color: "hsl(var(--chart-3))", time: item.time,
}, name: `Point ${index + 1}`,
} satisfies ChartConfig; usedGb: +item.value.diskUsage,
totalGb: +item.value.diskTotal,
export const DockerDiskChart = ({ accumulativeData, diskTotal }: Props) => { freeGb: item.value.diskFree,
const transformedData = accumulativeData.map((item, index) => ({ };
time: item.time, });
name: `Point ${index + 1}`,
usedGb: +item.value.diskUsage,
totalGb: +item.value.diskTotal,
}));
return ( return (
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full"> <div className="mt-6 w-full h-[10rem]">
<AreaChart <ResponsiveContainer>
data={transformedData} <AreaChart
margin={{ top: 10, right: 10, left: 0, bottom: 0 }} data={transformedData}
> margin={{
<defs> top: 10,
<linearGradient id="fillDiskUsed" x1="0" y1="0" x2="0" y2="1"> right: 30,
<stop left: 0,
offset="5%" bottom: 0,
stopColor="var(--color-usedGb)" }}
stopOpacity={0.8} >
/> <defs>
<stop <linearGradient id="colorUsed" x1="0" y1="0" x2="0" y2="1">
offset="95%" <stop offset="5%" stopColor="#6C28D9" stopOpacity={0.8} />
stopColor="var(--color-usedGb)" <stop offset="95%" stopColor="#6C28D9" stopOpacity={0} />
stopOpacity={0.1} </linearGradient>
/> <linearGradient id="colorFree" x1="0" y1="0" x2="0" y2="1">
</linearGradient> <stop offset="5%" stopColor="#6C28D9" stopOpacity={0.2} />
</defs> <stop offset="95%" stopColor="#6C28D9" stopOpacity={0} />
<CartesianGrid vertical={false} /> </linearGradient>
<YAxis </defs>
domain={[0, diskTotal]} <YAxis stroke="#A1A1AA" domain={[0, diskTotal]} />
tickLine={false} <CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
axisLine={false} {/* @ts-ignore */}
tickFormatter={(value) => `${value} GB`} <Tooltip content={<CustomTooltip />} />
/> <Legend />
<ChartTooltip <Area
cursor={false} type="monotone"
content={ dataKey="usedGb"
<ChartTooltipContent stroke="#6C28D9"
labelFormatter={(_, payload) => { fillOpacity={1}
const time = payload?.[0]?.payload?.time; fill="url(#colorUsed)"
return time ? format(new Date(time), "PPpp") : ""; name="Used GB"
}} />
formatter={(value) => { <Area
return [`${value} GB`, "Used"]; type="monotone"
}} dataKey="freeGb"
/> stroke="#8884d8"
} fillOpacity={1}
/> fill="url(#colorFree)"
<Area name="Free GB"
type="monotone" />
dataKey="usedGb" </AreaChart>
stroke="var(--color-usedGb)" </ResponsiveContainer>
fill="url(#fillDiskUsed)" </div>
strokeWidth={2}
/>
</AreaChart>
</ChartContainer>
); );
}; };
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
usedGb: number;
freeGb: number;
totalGb: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0]) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
<p>{`Disk usage: ${payload[0].payload.usedGb} GB`}</p>
<p>{`Disk free: ${payload[0].payload.freeGb} GB`}</p>
<p>{`Total disk: ${payload[0].payload.totalGb} GB`}</p>
</div>
);
}
return null;
};

View File

@@ -1,182 +0,0 @@
import { Loader2, RefreshCw } from "lucide-react";
import { useMemo } from "react";
import { Cell, Label, Pie, PieChart } from "recharts";
import { Button } from "@/components/ui/button";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { api } from "@/utils/api";
const TYPE_TO_KEY: Record<string, string> = {
Images: "images",
Containers: "containers",
"Local Volumes": "volumes",
"Build Cache": "buildCache",
};
const chartConfig = {
value: {
label: "Size",
},
images: {
label: "Images",
color: "hsl(var(--chart-1))",
},
containers: {
label: "Containers",
color: "hsl(var(--chart-2))",
},
volumes: {
label: "Volumes",
color: "hsl(var(--chart-3))",
},
buildCache: {
label: "Build Cache",
color: "hsl(var(--chart-4))",
},
} satisfies ChartConfig;
const formatSize = (bytes: number): string => {
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
};
export const DockerDiskUsageChart = () => {
const { data, isLoading, refetch, isRefetching } =
api.settings.getDockerDiskUsage.useQuery(undefined, {
refetchOnWindowFocus: false,
});
const { chartData, totalBytes } = useMemo(() => {
const items =
data
?.filter((item) => item.sizeBytes > 0)
.map((item) => {
const key = TYPE_TO_KEY[item.type] ?? item.type;
return {
name: key,
value: item.sizeBytes,
size: item.size,
active: item.active,
totalCount: item.totalCount,
reclaimable: item.reclaimable,
fill: `var(--color-${key})`,
};
}) ?? [];
return {
chartData: items,
totalBytes: items.reduce((sum, item) => sum + item.value, 0),
};
}, [data]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-[16rem]">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
);
}
if (chartData.length === 0) {
return (
<p className="text-xs text-muted-foreground mt-4">
No Docker disk usage data available.
</p>
);
}
return (
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Total: {formatSize(totalBytes)}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => refetch()}
disabled={isRefetching}
>
<RefreshCw
className={`size-3.5 ${isRefetching ? "animate-spin" : ""}`}
/>
</Button>
</div>
<ChartContainer
config={chartConfig}
className="mx-auto w-full max-h-[250px] [&_.recharts-pie-label-text]:fill-foreground"
>
<PieChart>
<ChartTooltip
content={
<ChartTooltipContent
nameKey="name"
formatter={(value, name) => {
const item = chartData.find((d) => d.name === name);
if (!item) return [formatSize(value as number), name];
return [
`${item.size}${item.active} active / ${item.totalCount} total — Reclaimable: ${item.reclaimable}`,
chartConfig[name as keyof typeof chartConfig]?.label ??
name,
];
}}
/>
}
/>
<Pie
data={chartData}
dataKey="value"
nameKey="name"
innerRadius={60}
outerRadius={85}
strokeWidth={3}
stroke="hsl(var(--background))"
minAngle={15}
>
{chartData.map((entry) => (
<Cell key={entry.name} fill={entry.fill} />
))}
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) - 8}
className="fill-foreground text-2xl font-bold"
>
{formatSize(totalBytes)}
</tspan>
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) + 14}
className="fill-muted-foreground text-xs"
>
Docker Usage
</tspan>
</text>
);
}
}}
/>
</Pie>
<ChartLegend content={<ChartLegendContent nameKey="name" />} />
</PieChart>
</ChartContainer>
</div>
);
};

View File

@@ -1,87 +1,93 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import { import {
type ChartConfig, Area,
ChartContainer, AreaChart,
ChartLegend, CartesianGrid,
ChartLegendContent, Legend,
ChartTooltip, ResponsiveContainer,
ChartTooltipContent, Tooltip,
} from "@/components/ui/chart"; YAxis,
} from "recharts";
import type { DockerStatsJSON } from "./show-free-container-monitoring"; import type { DockerStatsJSON } from "./show-free-container-monitoring";
import { convertMemoryToBytes } from "./show-free-container-monitoring"; import { convertMemoryToBytes } from "./show-free-container-monitoring";
interface Props { interface Props {
accumulativeData: DockerStatsJSON["memory"]; acummulativeData: DockerStatsJSON["memory"];
memoryLimitGB: number; memoryLimitGB: number;
} }
const chartConfig = {
usage: {
label: "Memory (GB)",
color: "hsl(var(--chart-2))",
},
} satisfies ChartConfig;
export const DockerMemoryChart = ({ export const DockerMemoryChart = ({
accumulativeData, acummulativeData,
memoryLimitGB, memoryLimitGB,
}: Props) => { }: Props) => {
const transformedData = accumulativeData.map((item, index) => ({ const transformedData = acummulativeData.map((item, index) => {
time: item.time, return {
name: `Point ${index + 1}`, time: item.time,
// @ts-ignore name: `Point ${index + 1}`,
usage: (convertMemoryToBytes(item.value.used) / 1024 ** 3).toFixed(2), // @ts-ignore
})); usage: (convertMemoryToBytes(item.value.used) / 1024 ** 3).toFixed(2),
};
});
return ( return (
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full"> <div className="mt-6 w-full h-[10rem]">
<AreaChart <ResponsiveContainer>
data={transformedData} <AreaChart
margin={{ top: 10, right: 10, left: 0, bottom: 0 }} data={transformedData}
> margin={{
<defs> top: 10,
<linearGradient id="fillMemory" x1="0" y1="0" x2="0" y2="1"> right: 30,
<stop left: 0,
offset="5%" bottom: 0,
stopColor="var(--color-usage)" }}
stopOpacity={0.8} >
/> <defs>
<stop <linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
offset="95%" <stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
stopColor="var(--color-usage)" <stop offset="95%" stopColor="white" stopOpacity={0} />
stopOpacity={0.1} </linearGradient>
/> </defs>
</linearGradient> <YAxis stroke="#A1A1AA" domain={[0, +memoryLimitGB.toFixed(2)]} />
</defs> <CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
<CartesianGrid vertical={false} /> {/* @ts-ignore */}
<YAxis <Tooltip content={<CustomTooltip />} />
tickFormatter={(value) => `${value} GB`} <Legend />
domain={[0, +memoryLimitGB.toFixed(2)]} <Area
tickLine={false} type="monotone"
axisLine={false} dataKey="usage"
/> stroke="#27272A"
<ChartTooltip fillOpacity={1}
cursor={false} fill="url(#colorUv)"
content={ />
<ChartTooltipContent </AreaChart>
labelFormatter={(_, payload) => { </ResponsiveContainer>
const time = payload?.[0]?.payload?.time; </div>
return time ? format(new Date(time), "PPpp") : "";
}}
formatter={(value) => [`${value} GB`, "Memory"]}
/>
}
/>
<Area
type="monotone"
dataKey="usage"
stroke="var(--color-usage)"
fill="url(#fillMemory)"
strokeWidth={2}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
); );
}; };
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
usage: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0] && payload[0].payload) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
{payload[0].payload.time && (
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
)}
<p>{`Memory usage: ${payload[0].payload.usage} GB`}</p>
</div>
);
}
return null;
};

View File

@@ -1,99 +1,99 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import { import {
type ChartConfig, Area,
ChartContainer, AreaChart,
ChartLegend, CartesianGrid,
ChartLegendContent, Legend,
ChartTooltip, ResponsiveContainer,
ChartTooltipContent, Tooltip,
} from "@/components/ui/chart"; YAxis,
} from "recharts";
import type { DockerStatsJSON } from "./show-free-container-monitoring"; import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props { interface Props {
accumulativeData: DockerStatsJSON["network"]; acummulativeData: DockerStatsJSON["network"];
} }
const chartConfig = { export const DockerNetworkChart = ({ acummulativeData }: Props) => {
inMB: { const transformedData = acummulativeData.map((item, index) => {
label: "In (MB)", return {
color: "hsl(var(--chart-1))", time: item.time,
}, name: `Point ${index + 1}`,
outMB: { inMB: item.value.inputMb,
label: "Out (MB)", outMB: item.value.outputMb,
color: "hsl(var(--chart-2))", };
}, });
} satisfies ChartConfig;
export const DockerNetworkChart = ({ accumulativeData }: Props) => {
const transformedData = accumulativeData.map((item, index) => ({
time: item.time,
name: `Point ${index + 1}`,
inMB: item.value.inputMb,
outMB: item.value.outputMb,
}));
return ( return (
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full"> <div className="mt-6 w-full h-[10rem]">
<AreaChart <ResponsiveContainer>
data={transformedData} <AreaChart
margin={{ top: 10, right: 10, left: 0, bottom: 0 }} data={transformedData}
> margin={{
<defs> top: 10,
<linearGradient id="fillNetIn" x1="0" y1="0" x2="0" y2="1"> right: 30,
<stop offset="5%" stopColor="var(--color-inMB)" stopOpacity={0.8} /> left: 0,
<stop bottom: 0,
offset="95%" }}
stopColor="var(--color-inMB)" >
stopOpacity={0.1} <defs>
/> <linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
</linearGradient> <stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
<linearGradient id="fillNetOut" x1="0" y1="0" x2="0" y2="1"> <stop offset="95%" stopColor="white" stopOpacity={0} />
<stop </linearGradient>
offset="5%" </defs>
stopColor="var(--color-outMB)" <YAxis stroke="#A1A1AA" />
stopOpacity={0.8} <CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
/> {/* @ts-ignore */}
<stop <Tooltip content={<CustomTooltip />} />
offset="95%" <Legend />
stopColor="var(--color-outMB)" <Area
stopOpacity={0.1} type="monotone"
/> dataKey="inMB"
</linearGradient> stroke="#8884d8"
</defs> fillOpacity={1}
<CartesianGrid vertical={false} /> fill="url(#colorUv)"
<YAxis tickLine={false} axisLine={false} /> name="In MB"
<ChartTooltip />
cursor={false} <Area
content={ type="monotone"
<ChartTooltipContent dataKey="outMB"
labelFormatter={(_, payload) => { stroke="#82ca9d"
const time = payload?.[0]?.payload?.time; fillOpacity={1}
return time ? format(new Date(time), "PPpp") : ""; fill="url(#colorUv)"
}} name="Out MB"
formatter={(value, name) => { />
const label = name === "inMB" ? "In" : "Out"; </AreaChart>
return [`${value} MB`, label]; </ResponsiveContainer>
}} </div>
/>
}
/>
<Area
type="monotone"
dataKey="inMB"
stroke="var(--color-inMB)"
fill="url(#fillNetIn)"
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="outMB"
stroke="var(--color-outMB)"
fill="url(#fillNetOut)"
strokeWidth={2}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
); );
}; };
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
inMB: number;
outMB: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0]) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
{payload[0].payload.time && (
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
)}
<p>{`In Usage: ${payload[0].payload.inMB} `}</p>
<p>{`Out Usage: ${payload[0].payload.outMB} `}</p>
</div>
);
}
return null;
};

View File

@@ -5,7 +5,6 @@ import { api } from "@/utils/api";
import { DockerBlockChart } from "./docker-block-chart"; import { DockerBlockChart } from "./docker-block-chart";
import { DockerCpuChart } from "./docker-cpu-chart"; import { DockerCpuChart } from "./docker-cpu-chart";
import { DockerDiskChart } from "./docker-disk-chart"; import { DockerDiskChart } from "./docker-disk-chart";
import { DockerDiskUsageChart } from "./docker-disk-usage-chart";
import { DockerMemoryChart } from "./docker-memory-chart"; import { DockerMemoryChart } from "./docker-memory-chart";
import { DockerNetworkChart } from "./docker-network-chart"; import { DockerNetworkChart } from "./docker-network-chart";
@@ -125,7 +124,7 @@ export const ContainerFreeMonitoring = ({
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}, },
); );
const [accumulativeData, setAccumulativeData] = useState<DockerStatsJSON>({ const [acummulativeData, setAcummulativeData] = useState<DockerStatsJSON>({
cpu: [], cpu: [],
memory: [], memory: [],
block: [], block: [],
@@ -137,7 +136,7 @@ export const ContainerFreeMonitoring = ({
useEffect(() => { useEffect(() => {
setCurrentData(defaultData); setCurrentData(defaultData);
setAccumulativeData({ setAcummulativeData({
cpu: [], cpu: [],
memory: [], memory: [],
block: [], block: [],
@@ -156,7 +155,7 @@ export const ContainerFreeMonitoring = ({
network: data.network[data.network.length - 1] ?? currentData.network, network: data.network[data.network.length - 1] ?? currentData.network,
disk: data.disk[data.disk.length - 1] ?? currentData.disk, disk: data.disk[data.disk.length - 1] ?? currentData.disk,
}); });
setAccumulativeData({ setAcummulativeData({
block: data?.block || [], block: data?.block || [],
cpu: data?.cpu || [], cpu: data?.cpu || [],
disk: data?.disk || [], disk: data?.disk || [],
@@ -185,7 +184,7 @@ export const ContainerFreeMonitoring = ({
setCurrentData(data); setCurrentData(data);
const MAX_DATA_POINTS = 300; const MAX_DATA_POINTS = 300;
setAccumulativeData((prevData) => ({ setAcummulativeData((prevData) => ({
cpu: [...prevData.cpu, data.cpu].slice(-MAX_DATA_POINTS), cpu: [...prevData.cpu, data.cpu].slice(-MAX_DATA_POINTS),
memory: [...prevData.memory, data.memory].slice(-MAX_DATA_POINTS), memory: [...prevData.memory, data.memory].slice(-MAX_DATA_POINTS),
block: [...prevData.block, data.block].slice(-MAX_DATA_POINTS), block: [...prevData.block, data.block].slice(-MAX_DATA_POINTS),
@@ -229,7 +228,7 @@ export const ContainerFreeMonitoring = ({
)} )}
className="w-[100%]" className="w-[100%]"
/> />
<DockerCpuChart accumulativeData={accumulativeData.cpu} /> <DockerCpuChart acummulativeData={acummulativeData.cpu} />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -253,7 +252,7 @@ export const ContainerFreeMonitoring = ({
className="w-[100%]" className="w-[100%]"
/> />
<DockerMemoryChart <DockerMemoryChart
accumulativeData={accumulativeData.memory} acummulativeData={acummulativeData.memory}
memoryLimitGB={ memoryLimitGB={
// @ts-ignore // @ts-ignore
convertMemoryToBytes(currentData.memory.value.total) / convertMemoryToBytes(currentData.memory.value.total) /
@@ -278,25 +277,13 @@ export const ContainerFreeMonitoring = ({
className="w-[100%]" className="w-[100%]"
/> />
<DockerDiskChart <DockerDiskChart
accumulativeData={accumulativeData.disk} acummulativeData={acummulativeData.disk}
diskTotal={currentData.disk.value.diskTotal} diskTotal={currentData.disk.value.diskTotal}
/> />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
)} )}
{appName === "dokploy" && (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Docker Disk Usage
</CardTitle>
</CardHeader>
<CardContent>
<DockerDiskUsageChart />
</CardContent>
</Card>
)}
<Card className="bg-background"> <Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
@@ -307,7 +294,7 @@ export const ContainerFreeMonitoring = ({
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{`Read: ${currentData.block.value.readMb} / Write: ${currentData.block.value.writeMb} `} {`Read: ${currentData.block.value.readMb} / Write: ${currentData.block.value.writeMb} `}
</span> </span>
<DockerBlockChart accumulativeData={accumulativeData.block} /> <DockerBlockChart acummulativeData={acummulativeData.block} />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -320,7 +307,7 @@ export const ContainerFreeMonitoring = ({
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{`In MB: ${currentData.network.value.inputMb} / Out MB: ${currentData.network.value.outputMb} `} {`In MB: ${currentData.network.value.inputMb} / Out MB: ${currentData.network.value.outputMb} `}
</span> </span>
<DockerNetworkChart accumulativeData={accumulativeData.network} /> <DockerNetworkChart acummulativeData={acummulativeData.network} />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -21,8 +21,6 @@ interface Props {
} }
export const ShowGeneralMysql = ({ mysqlId }: Props) => { export const ShowGeneralMysql = ({ mysqlId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canDeploy = permissions?.deployment.create ?? false;
const { data, refetch } = api.mysql.one.useQuery( const { data, refetch } = api.mysql.one.useQuery(
{ {
mysqlId, mysqlId,
@@ -73,158 +71,153 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap"> <CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
{canDeploy && ( <DialogAction
<DialogAction title="Deploy MySQL"
title="Deploy MySQL" description="Are you sure you want to deploy this mysql?"
description="Are you sure you want to deploy this mysql?" type="default"
type="default" onClick={async () => {
onClick={async () => { setIsDeploying(true);
setIsDeploying(true); await new Promise((resolve) => setTimeout(resolve, 1000));
await new Promise((resolve) => setTimeout(resolve, 1000)); refetch();
refetch(); }}
}} >
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Button <Tooltip>
variant="default" <TooltipTrigger asChild>
isLoading={data?.applicationStatus === "running"} <div className="flex items-center">
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" <Rocket className="size-4 mr-1" />
> Deploy
<Tooltip> </div>
<TooltipTrigger asChild> </TooltipTrigger>
<div className="flex items-center"> <TooltipPrimitive.Portal>
<Rocket className="size-4 mr-1" /> <TooltipContent sideOffset={5} className="z-[60]">
Deploy <p>Downloads and sets up the MySQL database</p>
</div> </TooltipContent>
</TooltipTrigger> </TooltipPrimitive.Portal>
<TooltipPrimitive.Portal> </Tooltip>
<TooltipContent sideOffset={5} className="z-[60]"> </Button>
<p>Downloads and sets up the MySQL database</p> </DialogAction>
</TooltipContent> <DialogAction
</TooltipPrimitive.Portal> title="Reload MySQL"
</Tooltip> description="Are you sure you want to reload this mysql?"
</Button> type="default"
</DialogAction> onClick={async () => {
)} await reload({
{canDeploy && ( mysqlId: mysqlId,
appName: data?.appName || "",
})
.then(() => {
toast.success("MySQL reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading MySQL");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MySQL service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
{data?.applicationStatus === "idle" ? (
<DialogAction <DialogAction
title="Reload MySQL" title="Start MySQL"
description="Are you sure you want to reload this mysql?" description="Are you sure you want to start this mysql?"
type="default" type="default"
onClick={async () => { onClick={async () => {
await reload({ await start({
mysqlId: mysqlId, mysqlId: mysqlId,
appName: data?.appName || "",
}) })
.then(() => { .then(() => {
toast.success("MySQL reloaded successfully"); toast.success("MySQL started successfully");
refetch(); refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error reloading MySQL"); toast.error("Error starting MySQL");
}); });
}} }}
> >
<Button <Button
variant="secondary" variant="secondary"
isLoading={isReloading} isLoading={isStarting}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center"> <div className="flex items-center">
<RefreshCcw className="size-4 mr-1" /> <CheckCircle2 className="size-4 mr-1" />
Reload Start
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MySQL service without rebuilding</p> <p>
Start the MySQL database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop MySQL"
description="Are you sure you want to stop this mysql?"
onClick={async () => {
await stop({
mysqlId: mysqlId,
})
.then(() => {
toast.success("MySQL stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping MySQL");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running MySQL database</p>
</TooltipContent> </TooltipContent>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
</Tooltip> </Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
)} )}
{canDeploy &&
(data?.applicationStatus === "idle" ? (
<DialogAction
title="Start MySQL"
description="Are you sure you want to start this mysql?"
type="default"
onClick={async () => {
await start({
mysqlId: mysqlId,
})
.then(() => {
toast.success("MySQL started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting MySQL");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the MySQL database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop MySQL"
description="Are you sure you want to stop this mysql?"
onClick={async () => {
await stop({
mysqlId: mysqlId,
})
.then(() => {
toast.success("MySQL stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping MySQL");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running MySQL database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
))}
</TooltipProvider> </TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}

View File

@@ -1,19 +1,14 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input"; import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props { interface Props {
mysqlId: string; mysqlId: string;
} }
export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => { export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
const { data } = api.mysql.one.useQuery({ mysqlId }); const { data } = api.mysql.one.useQuery({ mysqlId });
const utils = api.useUtils();
const { mutateAsync: changePassword } =
api.mysql.changePassword.useMutation();
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
@@ -33,43 +28,20 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Password</Label> <Label>Password</Label>
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-4">
<ToggleVisibilityInput <ToggleVisibilityInput
disabled disabled
value={data?.databasePassword} value={data?.databasePassword}
/> />
<UpdateDatabasePassword
onUpdatePassword={async (newPassword) => {
await changePassword({
mysqlId,
password: newPassword,
type: "user",
});
toast.success("Password updated successfully");
utils.mysql.one.invalidate({ mysqlId });
}}
/>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Root Password</Label> <Label>Root Password</Label>
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-4">
<ToggleVisibilityInput <ToggleVisibilityInput
disabled disabled
value={data?.databaseRootPassword} value={data?.databaseRootPassword}
/> />
<UpdateDatabasePassword
label="Root Password"
onUpdatePassword={async (newPassword) => {
await changePassword({
mysqlId,
password: newPassword,
type: "root",
});
toast.success("Root password updated successfully");
utils.mysql.one.invalidate({ mysqlId });
}}
/>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">

View File

@@ -24,6 +24,7 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
const organizationSchema = z.object({ const organizationSchema = z.object({
@@ -54,6 +55,8 @@ export function AddOrganization({ organizationId }: Props) {
const { mutateAsync, isPending } = organizationId const { mutateAsync, isPending } = organizationId
? api.organization.update.useMutation() ? api.organization.update.useMutation()
: api.organization.create.useMutation(); : api.organization.create.useMutation();
const { refetch: refetchActiveOrganization } =
authClient.useActiveOrganization();
const form = useForm<OrganizationFormValues>({ const form = useForm<OrganizationFormValues>({
resolver: zodResolver(organizationSchema), resolver: zodResolver(organizationSchema),
@@ -86,7 +89,7 @@ export function AddOrganization({ organizationId }: Props) {
utils.organization.all.invalidate(); utils.organization.all.invalidate();
if (organizationId) { if (organizationId) {
utils.organization.one.invalidate({ organizationId }); utils.organization.one.invalidate({ organizationId });
utils.organization.active.invalidate(); refetchActiveOrganization();
} }
setOpen(false); setOpen(false);
}) })

View File

@@ -42,7 +42,6 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }), redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
mariadb: () => mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () => application: () =>
@@ -57,7 +56,6 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
postgres: () => api.postgres.update.useMutation(), postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(), redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(), mysql: () => api.mysql.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(), mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(), application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(), mongo: () => api.mongo.update.useMutation(),
@@ -86,7 +84,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
form.reset({ form.reset({
dockerImage: data.dockerImage, dockerImage: data.dockerImage,
command: data.command || "", command: data.command || "",
args: (data as any).args?.map((arg: string) => ({ value: arg })) || [], args: data.args?.map((arg) => ({ value: arg })) || [],
}); });
} }
}, [data, form]); }, [data, form]);
@@ -97,7 +95,6 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
postgresId: id || "", postgresId: id || "",
redisId: id || "", redisId: id || "",
mysqlId: id || "", mysqlId: id || "",
libsqlId: id || "",
mariadbId: id || "", mariadbId: id || "",
dockerImage: formData?.dockerImage, dockerImage: formData?.dockerImage,
command: formData?.command, command: formData?.command,
@@ -147,14 +144,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
<FormItem> <FormItem>
<FormLabel>Command</FormLabel> <FormLabel>Command</FormLabel>
<FormControl> <FormControl>
<Input <Input placeholder="/bin/sh" {...field} />
placeholder={
type === "libsql"
? "sqld --db-path iku.db --http-listen-addr 0.0.0.0:8080 --grpc-listen-addr 0.0.0.0:5001 --admin-listen-addr 0.0.0.0:5000"
: "Custom command"
}
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />

View File

@@ -21,8 +21,6 @@ interface Props {
} }
export const ShowGeneralPostgres = ({ postgresId }: Props) => { export const ShowGeneralPostgres = ({ postgresId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canDeploy = permissions?.deployment.create ?? false;
const { data, refetch } = api.postgres.one.useQuery( const { data, refetch } = api.postgres.one.useQuery(
{ {
postgresId: postgresId, postgresId: postgresId,
@@ -75,162 +73,153 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap"> <CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider disableHoverableContent={false}> <TooltipProvider disableHoverableContent={false}>
{canDeploy && ( <DialogAction
<DialogAction title="Deploy PostgreSQL"
title="Deploy PostgreSQL" description="Are you sure you want to deploy this postgres?"
description="Are you sure you want to deploy this postgres?" type="default"
type="default" onClick={async () => {
onClick={async () => { setIsDeploying(true);
setIsDeploying(true); await new Promise((resolve) => setTimeout(resolve, 1000));
await new Promise((resolve) => setTimeout(resolve, 1000)); refetch();
refetch(); }}
}} >
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Button <Tooltip>
variant="default" <TooltipTrigger asChild>
isLoading={data?.applicationStatus === "running"} <div className="flex items-center">
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" <Rocket className="size-4 mr-1" />
> Deploy
<Tooltip> </div>
<TooltipTrigger asChild> </TooltipTrigger>
<div className="flex items-center"> <TooltipPrimitive.Portal>
<Rocket className="size-4 mr-1" /> <TooltipContent sideOffset={5} className="z-[60]">
Deploy <p>Downloads and sets up the PostgreSQL database</p>
</div> </TooltipContent>
</TooltipTrigger> </TooltipPrimitive.Portal>
<TooltipPrimitive.Portal> </Tooltip>
<TooltipContent sideOffset={5} className="z-[60]"> </Button>
<p>Downloads and sets up the PostgreSQL database</p> </DialogAction>
</TooltipContent> <DialogAction
</TooltipPrimitive.Portal> title="Reload PostgreSQL"
</Tooltip> description="Are you sure you want to reload this postgres?"
</Button> type="default"
</DialogAction> onClick={async () => {
)} await reload({
{canDeploy && ( postgresId: postgresId,
appName: data?.appName || "",
})
.then(() => {
toast.success("PostgreSQL reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading PostgreSQL");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the PostgreSQL service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
{data?.applicationStatus === "idle" ? (
<DialogAction <DialogAction
title="Reload PostgreSQL" title="Start PostgreSQL"
description="Are you sure you want to reload this postgres?" description="Are you sure you want to start this postgres?"
type="default" type="default"
onClick={async () => { onClick={async () => {
await reload({ await start({
postgresId: postgresId, postgresId: postgresId,
appName: data?.appName || "",
}) })
.then(() => { .then(() => {
toast.success("PostgreSQL reloaded successfully"); toast.success("PostgreSQL started successfully");
refetch(); refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error reloading PostgreSQL"); toast.error("Error starting PostgreSQL");
}); });
}} }}
> >
<Button <Button
variant="secondary" variant="secondary"
isLoading={isReloading} isLoading={isStarting}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center"> <div className="flex items-center">
<RefreshCcw className="size-4 mr-1" /> <CheckCircle2 className="size-4 mr-1" />
Reload Start
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipContent sideOffset={5} className="z-[60]">
<p> <p>
Restart the PostgreSQL service without rebuilding Start the PostgreSQL database (requires a previous
successful setup)
</p> </p>
</TooltipContent> </TooltipContent>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
</Tooltip> </Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
) : (
<DialogAction
title="Stop PostgreSQL"
description="Are you sure you want to stop this postgres?"
onClick={async () => {
await stop({
postgresId: postgresId,
})
.then(() => {
toast.success("PostgreSQL stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping PostgreSQL");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running PostgreSQL database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)} )}
{canDeploy &&
(data?.applicationStatus === "idle" ? (
<DialogAction
title="Start PostgreSQL"
description="Are you sure you want to start this postgres?"
type="default"
onClick={async () => {
await start({
postgresId: postgresId,
})
.then(() => {
toast.success("PostgreSQL started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting PostgreSQL");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the PostgreSQL database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop PostgreSQL"
description="Are you sure you want to stop this postgres?"
onClick={async () => {
await stop({
postgresId: postgresId,
})
.then(() => {
toast.success("PostgreSQL stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping PostgreSQL");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Stop the currently running PostgreSQL database
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
))}
</TooltipProvider> </TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}

View File

@@ -1,19 +1,14 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input"; import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props { interface Props {
postgresId: string; postgresId: string;
} }
export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => { export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
const { data } = api.postgres.one.useQuery({ postgresId }); const { data } = api.postgres.one.useQuery({ postgresId });
const utils = api.useUtils();
const { mutateAsync: changePassword } =
api.postgres.changePassword.useMutation();
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
@@ -33,21 +28,11 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Password</Label> <Label>Password</Label>
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-4">
<ToggleVisibilityInput <ToggleVisibilityInput
value={data?.databasePassword} value={data?.databasePassword}
disabled disabled
/> />
<UpdateDatabasePassword
onUpdatePassword={async (newPassword) => {
await changePassword({
postgresId,
password: newPassword,
});
toast.success("Password updated successfully");
utils.postgres.one.invalidate({ postgresId });
}}
/>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">

View File

@@ -43,7 +43,6 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { slugify } from "@/lib/slug"; import { slugify } from "@/lib/slug";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
const AddTemplateSchema = z.object({ const AddTemplateSchema = z.object({
name: z.string().min(1, { name: z.string().min(1, {
@@ -54,8 +53,9 @@ const AddTemplateSchema = z.object({
.min(1, { .min(1, {
message: "App name is required", message: "App name is required",
}) })
.regex(APP_NAME_REGEX, { .regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
message: APP_NAME_MESSAGE, message:
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
}), }),
description: z.string().optional(), description: z.string().optional(),
serverId: z.string().optional(), serverId: z.string().optional(),

View File

@@ -43,7 +43,6 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { slugify } from "@/lib/slug"; import { slugify } from "@/lib/slug";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
const AddComposeSchema = z.object({ const AddComposeSchema = z.object({
composeType: z.enum(["docker-compose", "stack"]).optional(), composeType: z.enum(["docker-compose", "stack"]).optional(),
@@ -55,8 +54,9 @@ const AddComposeSchema = z.object({
.min(1, { .min(1, {
message: "App name is required", message: "App name is required",
}) })
.regex(APP_NAME_REGEX, { .regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
message: APP_NAME_MESSAGE, message:
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
}), }),
description: z.string().optional(), description: z.string().optional(),
serverId: z.string().optional(), serverId: z.string().optional(),
@@ -78,6 +78,9 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
const { mutateAsync, isPending, error, isError } = const { mutateAsync, isPending, error, isError } =
api.compose.create.useMutation(); api.compose.create.useMutation();
// Get environment data to extract projectId
const { data: environment } = api.environment.one.useQuery({ environmentId });
const hasServers = servers && servers.length > 0; const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment // Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option) // Cloud: show only if there are remote servers (no Dokploy option)
@@ -114,8 +117,6 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
await utils.environment.one.invalidate({ await utils.environment.one.invalidate({
environmentId, environmentId,
}); });
// Invalidate the project query to refresh the project data for the advance-breadcrumb
await utils.project.all.invalidate();
}) })
.catch(() => { .catch(() => {
toast.error("Error creating the compose"); toast.error("Error creating the compose");

View File

@@ -5,7 +5,6 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { import {
LibsqlIcon,
MariadbIcon, MariadbIcon,
MongodbIcon, MongodbIcon,
MysqlIcon, MysqlIcon,
@@ -52,13 +51,11 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { slugify } from "@/lib/slug"; import { slugify } from "@/lib/slug";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
type DbType = z.infer<typeof mySchema>["type"]; type DbType = z.infer<typeof mySchema>["type"];
const dockerImageDefaultPlaceholder: Record<DbType, string> = { const dockerImageDefaultPlaceholder: Record<DbType, string> = {
mongo: "mongo:8", mongo: "mongo:7",
libsql: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
mariadb: "mariadb:11", mariadb: "mariadb:11",
mysql: "mysql:8", mysql: "mysql:8",
postgres: "postgres:18", postgres: "postgres:18",
@@ -69,9 +66,8 @@ const databasesUserDefaultPlaceholder: Record<
Exclude<DbType, "redis">, Exclude<DbType, "redis">,
string string
> = { > = {
libsql: "libsql",
mariadb: "mariadb",
mongo: "mongo", mongo: "mongo",
mariadb: "mariadb",
mysql: "mysql", mysql: "mysql",
postgres: "postgres", postgres: "postgres",
}; };
@@ -83,8 +79,9 @@ const baseDatabaseSchema = z.object({
.min(1, { .min(1, {
message: "App name is required", message: "App name is required",
}) })
.regex(APP_NAME_REGEX, { .regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
message: APP_NAME_MESSAGE, message:
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
}), }),
databasePassword: z databasePassword: z
.string() .string()
@@ -97,88 +94,56 @@ const baseDatabaseSchema = z.object({
serverId: z.string().nullable(), serverId: z.string().nullable(),
}); });
const mySchema = z const mySchema = z.discriminatedUnion("type", [
.discriminatedUnion("type", [ z
z .object({
.object({ type: z.literal("postgres"),
type: z.literal("libsql"), databaseName: z.string().default("postgres"),
dockerImage: z databaseUser: z.string().default("postgres"),
.string() })
.default("ghcr.io/tursodatabase/libsql-server:v0.24.32"), .merge(baseDatabaseSchema),
databaseUser: z.string().default("libsql"), z
sqldNode: z.enum(["primary", "replica"]).default("primary"), .object({
sqldPrimaryUrl: z.string().optional(), type: z.literal("mongo"),
enableNamespaces: z.boolean().default(false), databaseUser: z.string().default("mongo"),
}) replicaSets: z.boolean().default(false),
.merge(baseDatabaseSchema), })
z .merge(baseDatabaseSchema),
.object({ z
type: z.literal("mariadb"), .object({
dockerImage: z.string().default("mariadb:4"), type: z.literal("redis"),
databaseRootPassword: z })
.string() .merge(baseDatabaseSchema),
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, { z
message: .object({
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility", type: z.literal("mysql"),
}) databaseRootPassword: z
.optional(), .string()
databaseUser: z.string().default("mariadb"), .regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
databaseName: z.string().default("mariadb"),
})
.merge(baseDatabaseSchema),
z
.object({
type: z.literal("mongo"),
databaseUser: z.string().default("mongo"),
replicaSets: z.boolean().default(false),
})
.merge(baseDatabaseSchema),
z
.object({
type: z.literal("mysql"),
databaseRootPassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
})
.optional(),
databaseUser: z.string().default("mysql"),
databaseName: z.string().default("mysql"),
})
.merge(baseDatabaseSchema),
z
.object({
type: z.literal("postgres"),
databaseName: z.string().default("postgres"),
databaseUser: z.string().default("postgres"),
})
.merge(baseDatabaseSchema),
z
.object({
type: z.literal("redis"),
})
.merge(baseDatabaseSchema),
])
.superRefine((data, ctx) => {
if (data.type === "libsql") {
if (data.sqldNode === "replica" && !data.sqldPrimaryUrl) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["sqldPrimaryUrl"],
message: "sqldPrimaryUrl is required when sqldNode is 'replica'.",
});
}
if (data.sqldNode !== "replica" && data.sqldPrimaryUrl) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["sqldPrimaryUrl"],
message: message:
"sqldPrimaryUrl should not be provided when sqldNode is not 'replica'.", "Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
}); })
} .optional(),
} databaseUser: z.string().default("mysql"),
}); databaseName: z.string().default("mysql"),
})
.merge(baseDatabaseSchema),
z
.object({
type: z.literal("mariadb"),
dockerImage: z.string().default("mariadb:4"),
databaseRootPassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
})
.optional(),
databaseUser: z.string().default("mariadb"),
databaseName: z.string().default("mariadb"),
})
.merge(baseDatabaseSchema),
]);
const databasesMap = { const databasesMap = {
postgres: { postgres: {
@@ -201,10 +166,6 @@ const databasesMap = {
icon: <RedisIcon />, icon: <RedisIcon />,
label: "Redis", label: "Redis",
}, },
libsql: {
icon: <LibsqlIcon className="size-10" />,
label: "libSQL",
},
}; };
type AddDatabase = z.infer<typeof mySchema>; type AddDatabase = z.infer<typeof mySchema>;
@@ -220,12 +181,11 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
const slug = slugify(projectName); const slug = slugify(projectName);
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery(); const { data: servers } = api.server.withSSHKey.useQuery();
const libsqlMutation = api.libsql.create.useMutation();
const mariadbMutation = api.mariadb.create.useMutation();
const mongoMutation = api.mongo.create.useMutation();
const mysqlMutation = api.mysql.create.useMutation();
const postgresMutation = api.postgres.create.useMutation(); const postgresMutation = api.postgres.create.useMutation();
const mongoMutation = api.mongo.create.useMutation();
const redisMutation = api.redis.create.useMutation(); const redisMutation = api.redis.create.useMutation();
const mariadbMutation = api.mariadb.create.useMutation();
const mysqlMutation = api.mysql.create.useMutation();
// Get environment data to extract projectId // Get environment data to extract projectId
const { data: environment } = api.environment.one.useQuery({ environmentId }); const { data: environment } = api.environment.one.useQuery({ environmentId });
@@ -250,15 +210,13 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
}, },
resolver: zodResolver(mySchema), resolver: zodResolver(mySchema),
}); });
const sqldNode = form.watch("sqldNode");
const type = form.watch("type"); const type = form.watch("type");
const activeMutation = { const activeMutation = {
libsql: libsqlMutation,
mariadb: mariadbMutation,
mongo: mongoMutation,
mysql: mysqlMutation,
postgres: postgresMutation, postgres: postgresMutation,
mongo: mongoMutation,
redis: redisMutation, redis: redisMutation,
mariadb: mariadbMutation,
mysql: mysqlMutation,
}; };
const onSubmit = async (data: AddDatabase) => { const onSubmit = async (data: AddDatabase) => {
@@ -275,23 +233,12 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
description: data.description, description: data.description,
}; };
if (data.type === "libsql") { if (data.type === "postgres") {
promise = libsqlMutation.mutateAsync({ promise = postgresMutation.mutateAsync({
...commonParams,
sqldNode: data.sqldNode,
sqldPrimaryUrl: data.sqldPrimaryUrl ?? null,
enableNamespaces: data.enableNamespaces,
databasePassword: data.databasePassword,
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId === "dokploy" ? null : data.serverId,
});
} else if (data.type === "mariadb") {
promise = mariadbMutation.mutateAsync({
...commonParams, ...commonParams,
databasePassword: data.databasePassword, databasePassword: data.databasePassword,
databaseRootPassword: data.databaseRootPassword || "", databaseName: data.databaseName || "postgres",
databaseName: data.databaseName || "mariadb",
databaseUser: databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type], data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId === "dokploy" ? null : data.serverId, serverId: data.serverId === "dokploy" ? null : data.serverId,
@@ -305,6 +252,22 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
serverId: data.serverId === "dokploy" ? null : data.serverId, serverId: data.serverId === "dokploy" ? null : data.serverId,
replicaSets: data.replicaSets, replicaSets: data.replicaSets,
}); });
} else if (data.type === "redis") {
promise = redisMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
serverId: data.serverId === "dokploy" ? null : data.serverId,
});
} else if (data.type === "mariadb") {
promise = mariadbMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
databaseRootPassword: data.databaseRootPassword || "",
databaseName: data.databaseName || "mariadb",
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId === "dokploy" ? null : data.serverId,
});
} else if (data.type === "mysql") { } else if (data.type === "mysql") {
promise = mysqlMutation.mutateAsync({ promise = mysqlMutation.mutateAsync({
...commonParams, ...commonParams,
@@ -315,21 +278,6 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
serverId: data.serverId === "dokploy" ? null : data.serverId, serverId: data.serverId === "dokploy" ? null : data.serverId,
databaseRootPassword: data.databaseRootPassword || "", databaseRootPassword: data.databaseRootPassword || "",
}); });
} else if (data.type === "postgres") {
promise = postgresMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
databaseName: data.databaseName || "postgres",
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId === "dokploy" ? null : data.serverId,
});
} else if (data.type === "redis") {
promise = redisMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
serverId: data.serverId === "dokploy" ? null : data.serverId,
});
} }
if (promise) { if (promise) {
@@ -357,7 +305,6 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
}); });
} }
}; };
return ( return (
<Dialog open={visible} onOpenChange={setVisible}> <Dialog open={visible} onOpenChange={setVisible}>
<DialogTrigger className="w-full"> <DialogTrigger className="w-full">
@@ -559,8 +506,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
{(type === "mariadb" || {(type === "mysql" ||
type === "mysql" || type === "mariadb" ||
type === "postgres") && ( type === "postgres") && (
<FormField <FormField
control={form.control} control={form.control}
@@ -577,101 +524,10 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
)} )}
/> />
)} )}
{(type === "mysql" ||
{type === "libsql" && (
<FormField
control={form.control}
name="sqldNode"
render={({ field }) => (
<FormItem>
<FormLabel>Sqld Node</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || "primary"}
>
<SelectTrigger>
<SelectValue placeholder={"primary"} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{["primary", "replica"].map((node) => (
<SelectItem key={node} value={node}>
{node.charAt(0).toUpperCase() + node.slice(1)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
{type === "libsql" && sqldNode === "replica" && (
<FormField
control={form.control}
name="sqldPrimaryUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Sqld Primary URL</FormLabel>
<FormControl>
<Input
placeholder={"https://<host>:<port>"}
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{type === "libsql" && (
<FormField
control={form.control}
name="enableNamespaces"
render={({ field }) => {
console.log(field.value);
return (
<FormItem>
<FormLabel>Enable Namespaces</FormLabel>
<FormControl>
<Select
onValueChange={(value) =>
field.onChange(Boolean(value))
}
defaultValue={
field.value ? String(field.value) : "false"
}
>
<SelectTrigger>
<SelectValue placeholder={"false"} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{["false", "true"].map((node) => (
<SelectItem key={node} value={node}>
{node.charAt(0).toUpperCase() +
node.slice(1)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
)}
{(type === "libsql" ||
type === "mariadb" || type === "mariadb" ||
type === "mongo" || type === "postgres" ||
type === "mysql" || type === "mongo") && (
type === "postgres") && (
<FormField <FormField
control={form.control} control={form.control}
name="databaseUser" name="databaseUser"
@@ -712,7 +568,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
{(type === "mariadb" || type === "mysql") && ( {(type === "mysql" || type === "mariadb") && (
<FormField <FormField
control={form.control} control={form.control}
name="databaseRootPassword" name="databaseRootPassword"

View File

@@ -1,6 +1,5 @@
import { import {
BookText, BookText,
Bookmark,
CheckIcon, CheckIcon,
ChevronsUpDown, ChevronsUpDown,
Globe, Globe,
@@ -83,7 +82,6 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed"); const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed");
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [showBookmarksOnly, setShowBookmarksOnly] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState<string | undefined>(() => { const [customBaseUrl, setCustomBaseUrl] = useState<string | undefined>(() => {
// Try to get from props first, then localStorage // Try to get from props first, then localStorage
if (baseUrl) return baseUrl; if (baseUrl) return baseUrl;
@@ -124,45 +122,8 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
enabled: open, enabled: open,
}, },
); );
const { data: bookmarkIds = [], isLoading: isLoadingBookmarks } =
api.user.getBookmarkedTemplates.useQuery(undefined, {
enabled: open,
});
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync: toggleBookmark } =
api.user.toggleTemplateBookmark.useMutation({
onMutate: async ({ templateId }) => {
await utils.user.getBookmarkedTemplates.cancel();
const previousBookmarks = utils.user.getBookmarkedTemplates.getData();
utils.user.getBookmarkedTemplates.setData(undefined, (old = []) => {
if (old.includes(templateId)) {
return old.filter((id) => id !== templateId);
}
return [...old, templateId];
});
return { previousBookmarks };
},
onError: (err, variables, context) => {
if (context?.previousBookmarks) {
utils.user.getBookmarkedTemplates.setData(
undefined,
context.previousBookmarks,
);
}
toast.error("Failed to update bookmark");
},
onSuccess: (data) => {
toast.success(
data.isBookmarked ? "Added to bookmarks" : "Removed from bookmarks",
);
},
});
const [serverId, setServerId] = useState<string | undefined>(undefined); const [serverId, setServerId] = useState<string | undefined>(undefined);
const { mutateAsync, isPending, error, isError } = const { mutateAsync, isPending, error, isError } =
api.compose.deployTemplate.useMutation(); api.compose.deployTemplate.useMutation();
@@ -176,9 +137,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
query === "" || query === "" ||
template.name.toLowerCase().includes(query.toLowerCase()) || template.name.toLowerCase().includes(query.toLowerCase()) ||
template.description.toLowerCase().includes(query.toLowerCase()); template.description.toLowerCase().includes(query.toLowerCase());
const matchesBookmarks = return matchesTags && matchesQuery;
!showBookmarksOnly || bookmarkIds.includes(template.id);
return matchesTags && matchesQuery && matchesBookmarks;
}) || []; }) || [];
const hasServers = servers && servers.length > 0; const hasServers = servers && servers.length > 0;
@@ -187,14 +146,6 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers) // Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers; const shouldShowServerDropdown = hasServers;
const handleToggleBookmark = async (
e: React.MouseEvent,
templateId: string,
) => {
e.stopPropagation();
await toggleBookmark({ templateId });
};
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="w-full"> <DialogTrigger className="w-full">
@@ -292,20 +243,6 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<Button
variant={showBookmarksOnly ? "default" : "outline"}
size="icon"
onClick={() => setShowBookmarksOnly(!showBookmarksOnly)}
className="h-9 w-9 flex-shrink-0"
disabled={isLoadingBookmarks}
>
<Bookmark
className={cn(
"size-4",
showBookmarksOnly && "fill-current",
)}
/>
</Button>
<Button <Button
size="icon" size="icon"
onClick={() => onClick={() =>
@@ -362,19 +299,11 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
</div> </div>
</div> </div>
) : templates.length === 0 ? ( ) : templates.length === 0 ? (
<div className="flex flex-col justify-center items-center w-full gap-2 min-h-[50vh]"> <div className="flex justify-center items-center w-full gap-2 min-h-[50vh]">
<SearchIcon className="text-muted-foreground size-6" /> <SearchIcon className="text-muted-foreground size-6" />
<div className="text-xl font-medium text-muted-foreground"> <div className="text-xl font-medium text-muted-foreground">
{showBookmarksOnly No templates found
? "No bookmarked templates found"
: "No templates found"}
</div> </div>
{showBookmarksOnly && (
<p className="text-sm text-muted-foreground">
Click the bookmark icon on templates to add them to
bookmarks
</p>
)}
</div> </div>
) : ( ) : (
<div <div
@@ -394,32 +323,15 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
viewMode === "detailed" && "h-[400px]", viewMode === "detailed" && "h-[400px]",
)} )}
> >
<div className="absolute top-2 left-2 z-10"> <Badge className="absolute top-2 right-2" variant="blue">
<Button {template?.version}
variant="ghost" </Badge>
size="icon"
className="h-8 w-8 bg-background/80 backdrop-blur-sm hover:bg-background"
onClick={(e) => handleToggleBookmark(e, template.id)}
>
<Bookmark
className={cn(
"size-4",
bookmarkIds.includes(template.id) &&
"fill-yellow-400 text-yellow-400",
)}
/>
</Button>
</div>
<div className="absolute top-2 right-2">
<Badge variant="blue">{template?.version}</Badge>
</div>
<div <div
className={cn( className={cn(
"flex-none p-6 pb-3 flex flex-col items-center gap-4 bg-muted/30", "flex-none p-6 pb-3 flex flex-col items-center gap-4 bg-muted/30",
viewMode === "detailed" && "border-b", viewMode === "detailed" && "border-b",
)} )}
> >
{/** biome-ignore lint/performance/noImgElement: this is a valid use for img tag */}
<img <img
src={`${customBaseUrl || "https://templates.dokploy.com/"}/blueprints/${template?.id}/${template?.logo}`} src={`${customBaseUrl || "https://templates.dokploy.com/"}/blueprints/${template?.id}/${template?.logo}`}
className={cn( className={cn(

View File

@@ -57,13 +57,19 @@ export const AdvancedEnvironmentSelector = ({
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
// Get current user's permissions // Get current user's permissions
const { data: permissions } = api.user.getPermissions.useQuery(); const { data: currentUser } = api.user.get.useQuery();
// Check if user can create environments // Check if user can create environments
const canCreateEnvironments = !!permissions?.environment.create; const canCreateEnvironments =
currentUser?.role === "owner" ||
currentUser?.role === "admin" ||
currentUser?.canCreateEnvironments === true;
// Check if user can delete environments // Check if user can delete environments
const canDeleteEnvironments = !!permissions?.environment.delete; const canDeleteEnvironments =
currentUser?.role === "owner" ||
currentUser?.role === "admin" ||
currentUser?.canDeleteEnvironments === true;
const haveServices = const haveServices =
selectedEnvironment && selectedEnvironment &&
@@ -92,8 +98,6 @@ export const AdvancedEnvironmentSelector = ({
toast.success("Environment created successfully"); toast.success("Environment created successfully");
utils.environment.byProjectId.invalidate({ projectId }); utils.environment.byProjectId.invalidate({ projectId });
// Invalidate the project query to refresh the project data for the advance-breadcrumb
utils.project.all.invalidate();
setIsCreateDialogOpen(false); setIsCreateDialogOpen(false);
setName(""); setName("");
setDescription(""); setDescription("");

View File

@@ -28,14 +28,13 @@ export type Services = {
serverId?: string | null; serverId?: string | null;
name: string; name: string;
type: type:
| "application"
| "compose"
| "libsql"
| "mariadb" | "mariadb"
| "mongo" | "application"
| "mysql"
| "postgres" | "postgres"
| "redis"; | "mysql"
| "mongo"
| "redis"
| "compose";
description?: string | null; description?: string | null;
id: string; id: string;
createdAt: string; createdAt: string;

View File

@@ -39,9 +39,6 @@ interface Props {
} }
export const EnvironmentVariables = ({ environmentId, children }: Props) => { export const EnvironmentVariables = ({ environmentId, children }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canRead = permissions?.environmentEnvVars.read ?? false;
const canWrite = permissions?.environmentEnvVars.write ?? false;
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync, error, isError, isPending } = const { mutateAsync, error, isError, isPending } =
@@ -88,12 +85,7 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S // Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ( if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending && isOpen) {
(e.ctrlKey || e.metaKey) &&
e.code === "KeyS" &&
!isPending &&
isOpen
) {
e.preventDefault(); e.preventDefault();
form.handleSubmit(onSubmit)(); form.handleSubmit(onSubmit)();
} }
@@ -105,10 +97,6 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
}; };
}, [form, onSubmit, isPending, isOpen]); }, [form, onSubmit, isPending, isOpen]);
if (!canRead) {
return null;
}
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
@@ -153,7 +141,6 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
<CodeEditor <CodeEditor
lineWrapping lineWrapping
language="properties" language="properties"
readOnly={!canWrite}
wrapperClassName="h-[35rem] font-mono" wrapperClassName="h-[35rem] font-mono"
placeholder={`NODE_ENV=development placeholder={`NODE_ENV=development
DATABASE_URL=postgresql://localhost:5432/mydb DATABASE_URL=postgresql://localhost:5432/mydb
@@ -170,13 +157,11 @@ API_KEY=your-api-key-here
</FormItem> </FormItem>
)} )}
/> />
{canWrite && ( <DialogFooter>
<DialogFooter> <Button isLoading={isPending} type="submit">
<Button isLoading={isPending} type="submit"> Update
Update </Button>
</Button> </DialogFooter>
</DialogFooter>
)}
</form> </form>
</Form> </Form>
</div> </div>

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