mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-06 06:25:28 +02:00
Compare commits
118 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
178f4fbdf7 | ||
|
|
2c07a4b2e3 | ||
|
|
75a797097b | ||
|
|
2879816e41 | ||
|
|
3501996b9e | ||
|
|
47556a6486 | ||
|
|
e554adc376 | ||
|
|
1804b935f6 | ||
|
|
985c9102da | ||
|
|
2e03cf3d48 | ||
|
|
33532d3cf7 | ||
|
|
a6999b1cf2 | ||
|
|
f5d18d6f9b | ||
|
|
e3ff7ef3e3 | ||
|
|
b84bc9b7c6 | ||
|
|
de201d0b0a | ||
|
|
6866e2b63a | ||
|
|
3e4a1b92eb | ||
|
|
b9ca6ea9db | ||
|
|
f1d4543d5e | ||
|
|
d8c7c1eaf4 | ||
|
|
4330d7bd99 | ||
|
|
6e67864204 | ||
|
|
2102840bb9 | ||
|
|
30f061e774 | ||
|
|
c00aa6acbf | ||
|
|
8e9ab98a7a | ||
|
|
ce82e2322b | ||
|
|
ec7df05990 | ||
|
|
75a4e8e8ef | ||
|
|
b4319c7ea2 | ||
|
|
e9787b753d | ||
|
|
b419294b09 | ||
|
|
922b4d58f1 | ||
|
|
dc8ff78ee5 | ||
|
|
735c9952d8 | ||
|
|
21821295e3 | ||
|
|
a8467e80e8 | ||
|
|
95e14b4199 | ||
|
|
076262e479 | ||
|
|
c4f4db3ebc | ||
|
|
4882bd25ad | ||
|
|
7a8f2e53d5 | ||
|
|
50182a8048 | ||
|
|
35d35028f6 | ||
|
|
a5a4a1a818 | ||
|
|
c106d13ab5 | ||
|
|
808001d8de | ||
|
|
ce24eadbb4 | ||
|
|
b87f8cc5d8 | ||
|
|
f650200771 | ||
|
|
f961dc6e7a | ||
|
|
4be25da185 | ||
|
|
675c1d7a7d | ||
|
|
28cc361c47 | ||
|
|
cedec5239f | ||
|
|
2f4cbbd3ac | ||
|
|
38b20450dc | ||
|
|
49f43ab3fb | ||
|
|
2eae756cec | ||
|
|
70c261d021 | ||
|
|
9ae2ebff46 | ||
|
|
8ce880d108 | ||
|
|
34304526b1 | ||
|
|
a16c4c1294 | ||
|
|
d1c4ac20e3 | ||
|
|
0195119a86 | ||
|
|
48a577e792 | ||
|
|
bf7a75dd9f | ||
|
|
d316aa4401 | ||
|
|
f1b2cc35b3 | ||
|
|
d2fabc998d | ||
|
|
7185047eb7 | ||
|
|
7121fbe50a | ||
|
|
36cf3a69fc | ||
|
|
c34a01a173 | ||
|
|
9ac147a140 | ||
|
|
20f79ac655 | ||
|
|
6f21f1cc1f | ||
|
|
af76548482 | ||
|
|
13638d0f04 | ||
|
|
edceebec7e | ||
|
|
7599565e73 | ||
|
|
08c9113405 | ||
|
|
1014d4674c | ||
|
|
39b40c58bb | ||
|
|
1861e10b2a | ||
|
|
964e3c4150 | ||
|
|
e05f31d8c6 | ||
|
|
cc3b902d1e | ||
|
|
6c1f2372ed | ||
|
|
7da69862e1 | ||
|
|
612e73bb80 | ||
|
|
a360a259f5 | ||
|
|
149293f4d3 | ||
|
|
a8a5e1c6f1 | ||
|
|
4ede21eda9 | ||
|
|
e275e9162e | ||
|
|
60a6dc5fab | ||
|
|
705c5bc1c9 | ||
|
|
8d56544c1d | ||
|
|
ca527ab6ff | ||
|
|
439fa17292 | ||
|
|
096c04486c | ||
|
|
c9e1079076 | ||
|
|
e29a86a85f | ||
|
|
f9dedd979e | ||
|
|
1ba0eb0c2e | ||
|
|
d7dc10993e | ||
|
|
2a5d3975e8 | ||
|
|
9f3356ddb4 | ||
|
|
f5674f5bf8 | ||
|
|
17a617e585 | ||
|
|
f50eea9e05 | ||
|
|
81ee8f653a | ||
|
|
9507745cc0 | ||
|
|
d33e164876 | ||
|
|
7e6e815375 |
@@ -1,2 +1,11 @@
|
|||||||
LEMON_SQUEEZY_API_KEY=""
|
LEMON_SQUEEZY_API_KEY=""
|
||||||
LEMON_SQUEEZY_STORE_ID=""
|
LEMON_SQUEEZY_STORE_ID=""
|
||||||
|
|
||||||
|
# Inngest (for GET /jobs - list deployment queue). Self-hosted example:
|
||||||
|
# INNGEST_BASE_URL="http://localhost:8288"
|
||||||
|
# Production: INNGEST_BASE_URL="https://dev-inngest.dokploy.com"
|
||||||
|
# INNGEST_SIGNING_KEY="your-signing-key"
|
||||||
|
# Optional: only events after this RFC3339 timestamp. If unset, no date filter is applied.
|
||||||
|
# INNGEST_EVENTS_RECEIVED_AFTER="2024-01-01T00:00:00Z"
|
||||||
|
# Max events to fetch when listing jobs (paginates with cursor). Default 100, max 10000.
|
||||||
|
# INNGEST_JOBS_MAX_EVENTS=100
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type DeployJob,
|
type DeployJob,
|
||||||
deployJobSchema,
|
deployJobSchema,
|
||||||
} from "./schema.js";
|
} from "./schema.js";
|
||||||
|
import { fetchDeploymentJobs } from "./service.js";
|
||||||
import { deploy } from "./utils.js";
|
import { deploy } from "./utils.js";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -118,7 +119,6 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => {
|
|||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("error", error);
|
|
||||||
logger.error("Failed to send deployment event", error);
|
logger.error("Failed to send deployment event", error);
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
@@ -176,6 +176,29 @@ app.get("/health", async (c) => {
|
|||||||
return c.json({ status: "ok" });
|
return c.json({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// List deployment jobs (Inngest runs) for a server - same shape as BullMQ queue for the UI
|
||||||
|
app.get("/jobs", async (c) => {
|
||||||
|
const serverId = c.req.query("serverId");
|
||||||
|
if (!serverId) {
|
||||||
|
return c.json({ message: "serverId is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await fetchDeploymentJobs(serverId);
|
||||||
|
return c.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (message.includes("INNGEST_BASE_URL")) {
|
||||||
|
return c.json(
|
||||||
|
{ message: "INNGEST_BASE_URL is required to list deployment jobs" },
|
||||||
|
503,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.error("Failed to fetch jobs from Inngest", { serverId, error });
|
||||||
|
return c.json([], 200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Serve Inngest functions endpoint
|
// Serve Inngest functions endpoint
|
||||||
app.on(
|
app.on(
|
||||||
["GET", "POST", "PUT"],
|
["GET", "POST", "PUT"],
|
||||||
|
|||||||
239
apps/api/src/service.ts
Normal file
239
apps/api/src/service.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { logger } from "./logger.js";
|
||||||
|
|
||||||
|
const baseUrl = process.env.INNGEST_BASE_URL ?? "";
|
||||||
|
const signingKey = process.env.INNGEST_SIGNING_KEY ?? "";
|
||||||
|
|
||||||
|
const DEFAULT_MAX_EVENTS = 500;
|
||||||
|
const MAX_EVENTS = DEFAULT_MAX_EVENTS;
|
||||||
|
|
||||||
|
/** Event shape from GET /v1/events (https://api.inngest.com/v1/events) */
|
||||||
|
type InngestEventRow = {
|
||||||
|
internal_id?: string;
|
||||||
|
accountID?: string;
|
||||||
|
environmentID?: string;
|
||||||
|
source?: string;
|
||||||
|
sourceID?: string | null;
|
||||||
|
/** RFC3339 timestamp – API uses receivedAt, dev server may use received_at */
|
||||||
|
receivedAt?: string;
|
||||||
|
received_at?: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
user?: unknown;
|
||||||
|
ts: number;
|
||||||
|
v?: string | null;
|
||||||
|
metadata?: {
|
||||||
|
fetchedAt: string;
|
||||||
|
cachedUntil: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Run shape from GET /v1/events/{eventId}/runs – the actual job execution */
|
||||||
|
type InngestRun = {
|
||||||
|
run_id: string;
|
||||||
|
event_id: string;
|
||||||
|
status: string; // "Running" | "Completed" | "Failed" | "Cancelled" | "Queued"?
|
||||||
|
run_started_at?: string;
|
||||||
|
ended_at?: string | null;
|
||||||
|
output?: unknown;
|
||||||
|
// dev server / API may use different casing
|
||||||
|
run_started_at_ms?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getEventReceivedAt(ev: InngestEventRow): string | undefined {
|
||||||
|
return ev.receivedAt ?? ev.received_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map Inngest run status to BullMQ-style state for the UI */
|
||||||
|
function runStatusToState(
|
||||||
|
status: string,
|
||||||
|
): "pending" | "active" | "completed" | "failed" | "cancelled" {
|
||||||
|
const s = status.toLowerCase();
|
||||||
|
if (s === "running") return "active";
|
||||||
|
if (s === "completed") return "completed";
|
||||||
|
if (s === "failed") return "failed";
|
||||||
|
if (s === "cancelled") return "cancelled";
|
||||||
|
if (s === "queued") return "pending";
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchInngestEvents = async () => {
|
||||||
|
const maxEvents = MAX_EVENTS;
|
||||||
|
const all: InngestEventRow[] = [];
|
||||||
|
let cursor: string | undefined;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const params = new URLSearchParams({ limit: "100" });
|
||||||
|
if (cursor) {
|
||||||
|
params.set("cursor", cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/v1/events?${params}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${signingKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.warn("Inngest API error", {
|
||||||
|
status: res.status,
|
||||||
|
body: await res.text(),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
data?: InngestEventRow[];
|
||||||
|
cursor?: string;
|
||||||
|
nextCursor?: string;
|
||||||
|
};
|
||||||
|
const data = Array.isArray(body.data) ? body.data : [];
|
||||||
|
all.push(...data);
|
||||||
|
|
||||||
|
// Next page: API may return cursor/nextCursor, or use last event's internal_id (per API docs)
|
||||||
|
const nextCursor =
|
||||||
|
body.cursor ?? body.nextCursor ?? data[data.length - 1]?.internal_id;
|
||||||
|
const hasMore = data.length === 100 && nextCursor && all.length < maxEvents;
|
||||||
|
cursor = hasMore ? nextCursor : undefined;
|
||||||
|
} while (cursor);
|
||||||
|
|
||||||
|
return all.slice(0, maxEvents);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fetch runs for a single event (GET /v1/events/{eventId}/runs) – runs are the actual jobs */
|
||||||
|
export const fetchInngestRunsForEvent = async (
|
||||||
|
eventId: string,
|
||||||
|
): Promise<InngestRun[]> => {
|
||||||
|
const res = await fetch(
|
||||||
|
`${baseUrl}/v1/events/${encodeURIComponent(eventId)}/runs`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${signingKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.warn("Inngest runs API error", {
|
||||||
|
eventId,
|
||||||
|
status: res.status,
|
||||||
|
body: await res.text(),
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as { data?: InngestRun[] };
|
||||||
|
return Array.isArray(body.data) ? body.data : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One row for the queue UI (BullMQ-compatible shape) */
|
||||||
|
export type DeploymentJobRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
timestamp: number;
|
||||||
|
processedOn?: number;
|
||||||
|
finishedOn?: number;
|
||||||
|
failedReason?: string;
|
||||||
|
state: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Build queue rows from events + their runs (one row per run, or pending if no run yet) */
|
||||||
|
function buildDeploymentRowsFromRuns(
|
||||||
|
events: InngestEventRow[],
|
||||||
|
runsByEventId: Map<string, InngestRun[]>,
|
||||||
|
serverId: string,
|
||||||
|
): DeploymentJobRow[] {
|
||||||
|
const requested = events.filter(
|
||||||
|
(e) =>
|
||||||
|
e.name === "deployment/requested" &&
|
||||||
|
(e.data as Record<string, unknown>)?.serverId === serverId,
|
||||||
|
);
|
||||||
|
const rows: DeploymentJobRow[] = [];
|
||||||
|
|
||||||
|
for (const ev of requested) {
|
||||||
|
const data = (ev.data ?? {}) as Record<string, unknown>;
|
||||||
|
const runs = runsByEventId.get(ev.id) ?? [];
|
||||||
|
|
||||||
|
if (runs.length === 0) {
|
||||||
|
// Queued: event received but no run yet
|
||||||
|
rows.push({
|
||||||
|
id: ev.id,
|
||||||
|
name: ev.name,
|
||||||
|
data,
|
||||||
|
timestamp: ev.ts,
|
||||||
|
processedOn: ev.ts,
|
||||||
|
finishedOn: undefined,
|
||||||
|
failedReason: undefined,
|
||||||
|
state: "pending",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const run of runs) {
|
||||||
|
const state = runStatusToState(run.status);
|
||||||
|
const runStartedMs =
|
||||||
|
run.run_started_at_ms ??
|
||||||
|
(run.run_started_at ? new Date(run.run_started_at).getTime() : ev.ts);
|
||||||
|
const endedMs = run.ended_at
|
||||||
|
? new Date(run.ended_at).getTime()
|
||||||
|
: undefined;
|
||||||
|
const failedReason =
|
||||||
|
state === "failed" &&
|
||||||
|
run.output &&
|
||||||
|
typeof run.output === "object" &&
|
||||||
|
"error" in run.output
|
||||||
|
? String((run.output as { error?: unknown }).error)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
id: run.run_id,
|
||||||
|
name: ev.name,
|
||||||
|
data,
|
||||||
|
timestamp: runStartedMs,
|
||||||
|
processedOn: runStartedMs,
|
||||||
|
finishedOn:
|
||||||
|
state === "completed" || state === "failed" || state === "cancelled"
|
||||||
|
? endedMs
|
||||||
|
: undefined,
|
||||||
|
failedReason,
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch deployment jobs for a server: events → runs → rows (correct model: runs = jobs) */
|
||||||
|
export const fetchDeploymentJobs = async (
|
||||||
|
serverId: string,
|
||||||
|
): Promise<DeploymentJobRow[]> => {
|
||||||
|
if (!signingKey) {
|
||||||
|
logger.warn("INNGEST_SIGNING_KEY not set, returning empty jobs list");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!baseUrl) {
|
||||||
|
throw new Error("INNGEST_BASE_URL is required to list deployment jobs");
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await fetchInngestEvents();
|
||||||
|
|
||||||
|
const requestedForServer = events.filter(
|
||||||
|
(e) =>
|
||||||
|
e.name === "deployment/requested" &&
|
||||||
|
(e.data as Record<string, unknown>)?.serverId === serverId,
|
||||||
|
);
|
||||||
|
// Limit to avoid too many run fetches
|
||||||
|
const toFetch = requestedForServer.slice(0, 50);
|
||||||
|
const runsByEventId = new Map<string, InngestRun[]>();
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
toFetch.map(async (ev) => {
|
||||||
|
const runs = await fetchInngestRunsForEvent(ev.id);
|
||||||
|
runsByEventId.set(ev.id, runs);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return buildDeploymentRowsFromRuns(toFetch, runsByEventId, serverId);
|
||||||
|
};
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
updateCompose,
|
updateCompose,
|
||||||
updatePreviewDeployment,
|
updatePreviewDeployment,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import type { DeployJob } from "./schema";
|
import type { DeployJob } from "./schema.js";
|
||||||
|
|
||||||
export const deploy = async (job: DeployJob) => {
|
export const deploy = async (job: DeployJob) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,13 +14,18 @@ vi.mock("@dokploy/server/db", () => {
|
|||||||
set: vi.fn(() => chain),
|
set: vi.fn(() => chain),
|
||||||
where: vi.fn(() => chain),
|
where: vi.fn(() => chain),
|
||||||
returning: vi.fn().mockResolvedValue([{}] as any),
|
returning: vi.fn().mockResolvedValue([{}] as any),
|
||||||
|
from: vi.fn(() => chain),
|
||||||
|
innerJoin: vi.fn(() => chain),
|
||||||
|
then: (resolve: (v: any) => void) => {
|
||||||
|
resolve([]);
|
||||||
|
},
|
||||||
} as any;
|
} as any;
|
||||||
return chain;
|
return chain;
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db: {
|
db: {
|
||||||
select: vi.fn(),
|
select: vi.fn(() => createChainableMock()),
|
||||||
insert: vi.fn(),
|
insert: vi.fn(),
|
||||||
update: vi.fn(() => createChainableMock()),
|
update: vi.fn(() => createChainableMock()),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
@@ -31,6 +36,9 @@ vi.mock("@dokploy/server/db", () => {
|
|||||||
patch: {
|
patch: {
|
||||||
findMany: vi.fn().mockResolvedValue([]),
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
|
member: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,13 +15,18 @@ vi.mock("@dokploy/server/db", () => {
|
|||||||
set: vi.fn(() => chain),
|
set: vi.fn(() => chain),
|
||||||
where: vi.fn(() => chain),
|
where: vi.fn(() => chain),
|
||||||
returning: vi.fn().mockResolvedValue([{}]),
|
returning: vi.fn().mockResolvedValue([{}]),
|
||||||
|
from: vi.fn(() => chain),
|
||||||
|
innerJoin: vi.fn(() => chain),
|
||||||
|
then: (resolve: (v: any) => void) => {
|
||||||
|
resolve([]);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return chain;
|
return chain;
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db: {
|
db: {
|
||||||
select: vi.fn(),
|
select: vi.fn(() => createChainableMock()),
|
||||||
insert: vi.fn(),
|
insert: vi.fn(),
|
||||||
update: vi.fn(() => createChainableMock()),
|
update: vi.fn(() => createChainableMock()),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
@@ -32,6 +37,9 @@ vi.mock("@dokploy/server/db", () => {
|
|||||||
patch: {
|
patch: {
|
||||||
findMany: vi.fn().mockResolvedValue([]),
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
|
member: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ vi.mock("@dokploy/server/db", () => {
|
|||||||
chain.where = () => chain;
|
chain.where = () => chain;
|
||||||
chain.values = () => chain;
|
chain.values = () => chain;
|
||||||
chain.returning = () => Promise.resolve([{}]);
|
chain.returning = () => Promise.resolve([{}]);
|
||||||
chain.then = undefined;
|
chain.from = () => chain;
|
||||||
|
chain.innerJoin = () => chain;
|
||||||
|
chain.then = (resolve: (value: unknown) => void) => {
|
||||||
|
resolve([]);
|
||||||
|
};
|
||||||
|
|
||||||
const tableMock = {
|
const tableMock = {
|
||||||
findFirst: vi.fn(() => Promise.resolve(undefined)),
|
findFirst: vi.fn(() => Promise.resolve(undefined)),
|
||||||
@@ -21,7 +25,6 @@ vi.mock("@dokploy/server/db", () => {
|
|||||||
update: vi.fn(() => chain),
|
update: vi.fn(() => chain),
|
||||||
delete: vi.fn(() => chain),
|
delete: vi.fn(() => chain),
|
||||||
};
|
};
|
||||||
const createQueryMock = () => tableMock;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db: {
|
db: {
|
||||||
|
|||||||
@@ -48,6 +48,20 @@ const baseSettings: WebServerSettings = {
|
|||||||
urlCallback: "",
|
urlCallback: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
whitelabelingConfig: {
|
||||||
|
appName: null,
|
||||||
|
appDescription: null,
|
||||||
|
logoUrl: null,
|
||||||
|
faviconUrl: null,
|
||||||
|
customCss: null,
|
||||||
|
loginLogoUrl: null,
|
||||||
|
supportUrl: null,
|
||||||
|
docsUrl: null,
|
||||||
|
errorPageTitle: null,
|
||||||
|
errorPageDescription: null,
|
||||||
|
metaTitle: null,
|
||||||
|
footerText: null,
|
||||||
|
},
|
||||||
cleanupCacheApplications: false,
|
cleanupCacheApplications: false,
|
||||||
cleanupCacheOnCompose: false,
|
cleanupCacheOnCompose: false,
|
||||||
cleanupCacheOnPreviews: false,
|
cleanupCacheOnPreviews: false,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Clock,
|
Clock,
|
||||||
|
Copy,
|
||||||
Loader2,
|
Loader2,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
@@ -97,6 +99,12 @@ export const ShowDeployments = ({
|
|||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const webhookUrl = useMemo(
|
||||||
|
() =>
|
||||||
|
`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`,
|
||||||
|
[url, refreshToken, type],
|
||||||
|
);
|
||||||
|
|
||||||
const MAX_DESCRIPTION_LENGTH = 200;
|
const MAX_DESCRIPTION_LENGTH = 200;
|
||||||
|
|
||||||
const truncateDescription = (description: string): string => {
|
const truncateDescription = (description: string): string => {
|
||||||
@@ -224,11 +232,27 @@ export const ShowDeployments = ({
|
|||||||
<div className="flex flex-row items-center gap-2 flex-wrap">
|
<div className="flex flex-row items-center gap-2 flex-wrap">
|
||||||
<span>Webhook URL: </span>
|
<span>Webhook URL: </span>
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
<span className="break-all text-muted-foreground">
|
<Badge
|
||||||
{`${url}/api/deploy${
|
role="button"
|
||||||
type === "compose" ? "/compose" : ""
|
tabIndex={0}
|
||||||
}/${refreshToken}`}
|
aria-label="Copy webhook URL to clipboard"
|
||||||
</span>
|
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer whitespace-normal break-all"
|
||||||
|
variant="outline"
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
copy(webhookUrl);
|
||||||
|
toast.success("Copied to clipboard.");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
copy(webhookUrl);
|
||||||
|
toast.success("Copied to clipboard.");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{webhookUrl}
|
||||||
|
<Copy className="h-4 w-4 ml-2" />
|
||||||
|
</Badge>
|
||||||
{(type === "application" || type === "compose") && (
|
{(type === "application" || type === "compose") && (
|
||||||
<RefreshToken id={id} type={type} />
|
<RefreshToken id={id} type={type} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,613 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { inferRouterOutputs } from "@trpc/server";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowRight, ListTodo, Loader2, XCircle } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,10 +45,12 @@ 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);
|
||||||
@@ -180,7 +182,10 @@ 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 className="w-10 h-10" />
|
<Logo
|
||||||
|
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}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { PenBoxIcon } from "lucide-react";
|
import { PenBoxIcon } from "lucide-react";
|
||||||
import { useEffect } 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";
|
||||||
@@ -41,6 +41,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const UpdateMariadb = ({ mariadbId }: Props) => {
|
export const UpdateMariadb = ({ mariadbId }: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { mutateAsync, error, isError, isPending } =
|
const { mutateAsync, error, isError, isPending } =
|
||||||
api.mariadb.update.useMutation();
|
api.mariadb.update.useMutation();
|
||||||
@@ -79,6 +80,7 @@ export const UpdateMariadb = ({ mariadbId }: Props) => {
|
|||||||
utils.mariadb.one.invalidate({
|
utils.mariadb.one.invalidate({
|
||||||
mariadbId: mariadbId,
|
mariadbId: mariadbId,
|
||||||
});
|
});
|
||||||
|
setIsOpen(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error updating the Mariadb");
|
toast.error("Error updating the Mariadb");
|
||||||
@@ -87,7 +89,7 @@ export const UpdateMariadb = ({ mariadbId }: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { PenBoxIcon } from "lucide-react";
|
import { PenBoxIcon } from "lucide-react";
|
||||||
import { useEffect } 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";
|
||||||
@@ -41,6 +41,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const UpdateMysql = ({ mysqlId }: Props) => {
|
export const UpdateMysql = ({ mysqlId }: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { mutateAsync, error, isError, isPending } =
|
const { mutateAsync, error, isError, isPending } =
|
||||||
api.mysql.update.useMutation();
|
api.mysql.update.useMutation();
|
||||||
@@ -79,6 +80,7 @@ export const UpdateMysql = ({ mysqlId }: Props) => {
|
|||||||
utils.mysql.one.invalidate({
|
utils.mysql.one.invalidate({
|
||||||
mysqlId: mysqlId,
|
mysqlId: mysqlId,
|
||||||
});
|
});
|
||||||
|
setIsOpen(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error updating MySQL");
|
toast.error("Error updating MySQL");
|
||||||
@@ -87,7 +89,7 @@ export const UpdateMysql = ({ mysqlId }: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ 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({
|
||||||
@@ -55,8 +54,6 @@ 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),
|
||||||
@@ -89,7 +86,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 });
|
||||||
refetchActiveOrganization();
|
utils.organization.active.invalidate();
|
||||||
}
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
export type Services = {
|
export type Services = {
|
||||||
appName: string;
|
|
||||||
serverId?: string | null;
|
serverId?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
type:
|
type:
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
BookIcon,
|
BookIcon,
|
||||||
ExternalLinkIcon,
|
|
||||||
FolderInput,
|
FolderInput,
|
||||||
Loader2,
|
Loader2,
|
||||||
MoreHorizontalIcon,
|
MoreHorizontalIcon,
|
||||||
@@ -16,7 +15,6 @@ import { toast } from "sonner";
|
|||||||
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
||||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||||
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
|
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
|
||||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -40,10 +38,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
@@ -280,14 +276,6 @@ export const ShowProjects = () => {
|
|||||||
)
|
)
|
||||||
.reduce((acc, curr) => acc + curr, 0);
|
.reduce((acc, curr) => acc + curr, 0);
|
||||||
|
|
||||||
const haveServicesWithDomains = project?.environments
|
|
||||||
.map(
|
|
||||||
(env) =>
|
|
||||||
env.applications.length > 0 ||
|
|
||||||
env.compose.length > 0,
|
|
||||||
)
|
|
||||||
.some(Boolean);
|
|
||||||
|
|
||||||
// Find default environment from accessible environments, or fall back to first accessible environment
|
// Find default environment from accessible environments, or fall back to first accessible environment
|
||||||
const accessibleEnvironment =
|
const accessibleEnvironment =
|
||||||
project?.environments.find((env) => env.isDefault) ||
|
project?.environments.find((env) => env.isDefault) ||
|
||||||
@@ -313,122 +301,6 @@ export const ShowProjects = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
|
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
|
||||||
{haveServicesWithDomains ? (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100"
|
|
||||||
size="sm"
|
|
||||||
variant="default"
|
|
||||||
>
|
|
||||||
<ExternalLinkIcon className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
className="w-[200px] space-y-2 overflow-y-auto max-h-[400px]"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{project.environments.some(
|
|
||||||
(env) => env.applications.length > 0,
|
|
||||||
) && (
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel>
|
|
||||||
Applications
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
{project.environments.map((env) =>
|
|
||||||
env.applications.map((app) => (
|
|
||||||
<div key={app.applicationId}>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
|
|
||||||
{app.name}
|
|
||||||
<StatusTooltip
|
|
||||||
status={
|
|
||||||
app.applicationStatus
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{app.domains.map((domain) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={domain.domainId}
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
className="space-x-4 text-xs cursor-pointer justify-between"
|
|
||||||
target="_blank"
|
|
||||||
href={`${
|
|
||||||
domain.https
|
|
||||||
? "https"
|
|
||||||
: "http"
|
|
||||||
}://${domain.host}${
|
|
||||||
domain.path
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="truncate">
|
|
||||||
{domain.host}
|
|
||||||
</span>
|
|
||||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</div>
|
|
||||||
)),
|
|
||||||
)}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
)}
|
|
||||||
{project.environments.some(
|
|
||||||
(env) => env.compose.length > 0,
|
|
||||||
) && (
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel>
|
|
||||||
Compose
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
{project.environments.map((env) =>
|
|
||||||
env.compose.map((comp) => (
|
|
||||||
<div key={comp.composeId}>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
|
|
||||||
{comp.name}
|
|
||||||
<StatusTooltip
|
|
||||||
status={comp.composeStatus}
|
|
||||||
/>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{comp.domains.map((domain) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={domain.domainId}
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
className="space-x-4 text-xs cursor-pointer justify-between"
|
|
||||||
target="_blank"
|
|
||||||
href={`${
|
|
||||||
domain.https
|
|
||||||
? "https"
|
|
||||||
: "http"
|
|
||||||
}://${domain.host}${
|
|
||||||
domain.path
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="truncate">
|
|
||||||
{domain.host}
|
|
||||||
</span>
|
|
||||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</div>
|
|
||||||
)),
|
|
||||||
)}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
) : null}
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center justify-between gap-2 overflow-clip">
|
<CardTitle className="flex items-center justify-between gap-2 overflow-clip">
|
||||||
<span className="flex flex-col gap-1.5 ">
|
<span className="flex flex-col gap-1.5 ">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { PenBoxIcon } from "lucide-react";
|
import { PenBoxIcon } from "lucide-react";
|
||||||
import { useEffect } 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";
|
||||||
@@ -41,6 +41,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const UpdateRedis = ({ redisId }: Props) => {
|
export const UpdateRedis = ({ redisId }: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { mutateAsync, error, isError, isPending } =
|
const { mutateAsync, error, isError, isPending } =
|
||||||
api.redis.update.useMutation();
|
api.redis.update.useMutation();
|
||||||
@@ -79,6 +80,7 @@ export const UpdateRedis = ({ redisId }: Props) => {
|
|||||||
utils.redis.one.invalidate({
|
utils.redis.one.invalidate({
|
||||||
redisId: redisId,
|
redisId: redisId,
|
||||||
});
|
});
|
||||||
|
setIsOpen(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error updating Redis");
|
toast.error("Error updating Redis");
|
||||||
@@ -87,7 +89,7 @@ export const UpdateRedis = ({ redisId }: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { Button } from "@/components/ui/button";
|
|||||||
import type { LogEntry } from "./show-requests";
|
import type { LogEntry } from "./show-requests";
|
||||||
|
|
||||||
export const getStatusColor = (status: number) => {
|
export const getStatusColor = (status: number) => {
|
||||||
|
if (status === 0) {
|
||||||
|
return "secondary";
|
||||||
|
}
|
||||||
if (status >= 100 && status < 200) {
|
if (status >= 100 && status < 200) {
|
||||||
return "outline";
|
return "outline";
|
||||||
}
|
}
|
||||||
@@ -21,6 +24,24 @@ export const getStatusColor = (status: number) => {
|
|||||||
return "destructive";
|
return "destructive";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatStatusLabel = (status: number) => {
|
||||||
|
if (status === 0) {
|
||||||
|
return "N/A";
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (nanos: number) => {
|
||||||
|
const ms = nanos / 1000000;
|
||||||
|
if (ms < 1) {
|
||||||
|
return `${(nanos / 1000).toFixed(2)} µs`;
|
||||||
|
}
|
||||||
|
if (ms < 1000) {
|
||||||
|
return `${ms.toFixed(2)} ms`;
|
||||||
|
}
|
||||||
|
return `${(ms / 1000).toFixed(2)} s`;
|
||||||
|
};
|
||||||
|
|
||||||
export const columns: ColumnDef<LogEntry>[] = [
|
export const columns: ColumnDef<LogEntry>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "level",
|
accessorKey: "level",
|
||||||
@@ -59,10 +80,10 @@ export const columns: ColumnDef<LogEntry>[] = [
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-3 w-full">
|
<div className="flex flex-row gap-3 w-full">
|
||||||
<Badge variant={getStatusColor(log.OriginStatus)}>
|
<Badge variant={getStatusColor(log.OriginStatus)}>
|
||||||
Status: {log.OriginStatus}
|
Status: {formatStatusLabel(log.OriginStatus)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant={"secondary"}>
|
<Badge variant={"secondary"}>
|
||||||
Exec Time: {`${log.Duration / 1000000000}s`}
|
Exec Time: {formatDuration(log.Duration)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant={"secondary"}>IP: {log.ClientAddr}</Badge>
|
<Badge variant={"secondary"}>IP: {log.ClientAddr}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -152,7 +152,15 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
|
|||||||
return JSON.stringify(value, null, 2);
|
return JSON.stringify(value, null, 2);
|
||||||
}
|
}
|
||||||
if (key === "Duration" || key === "OriginDuration" || key === "Overhead") {
|
if (key === "Duration" || key === "OriginDuration" || key === "Overhead") {
|
||||||
return `${value / 1000000000} s`;
|
const nanos = Number(value);
|
||||||
|
const ms = nanos / 1000000;
|
||||||
|
if (ms < 1) {
|
||||||
|
return `${(nanos / 1000).toFixed(2)} µs`;
|
||||||
|
}
|
||||||
|
if (ms < 1000) {
|
||||||
|
return `${ms.toFixed(2)} ms`;
|
||||||
|
}
|
||||||
|
return `${(ms / 1000).toFixed(2)} s`;
|
||||||
}
|
}
|
||||||
if (key === "level") {
|
if (key === "level") {
|
||||||
return <Badge variant="secondary">{value}</Badge>;
|
return <Badge variant="secondary">{value}</Badge>;
|
||||||
@@ -161,7 +169,11 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
|
|||||||
return <Badge variant="outline">{value}</Badge>;
|
return <Badge variant="outline">{value}</Badge>;
|
||||||
}
|
}
|
||||||
if (key === "DownstreamStatus" || key === "OriginStatus") {
|
if (key === "DownstreamStatus" || key === "OriginStatus") {
|
||||||
return <Badge variant={getStatusColor(value)}>{value}</Badge>;
|
const num = Number(value);
|
||||||
|
if (num === 0) {
|
||||||
|
return <Badge variant="secondary">N/A</Badge>;
|
||||||
|
}
|
||||||
|
return <Badge variant={getStatusColor(num)}>{value}</Badge>;
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
CommandList,
|
CommandList,
|
||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
} from "@/components/ui/command";
|
} from "@/components/ui/command";
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { StatusTooltip } from "../shared/status-tooltip";
|
import { StatusTooltip } from "../shared/status-tooltip";
|
||||||
|
|
||||||
@@ -56,7 +55,7 @@ export const SearchCommand = () => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [search, setSearch] = React.useState("");
|
const [search, setSearch] = React.useState("");
|
||||||
const { data: session } = authClient.useSession();
|
const { data: session } = api.user.session.useQuery();
|
||||||
const { data } = api.project.all.useQuery(undefined, {
|
const { data } = api.project.all.useQuery(undefined, {
|
||||||
enabled: !!session,
|
enabled: !!session,
|
||||||
});
|
});
|
||||||
@@ -174,6 +173,14 @@ export const SearchCommand = () => {
|
|||||||
>
|
>
|
||||||
Projects
|
Projects
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
router.push("/dashboard/deployments");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Deployments
|
||||||
|
</CommandItem>
|
||||||
{!isCloud && (
|
{!isCloud && (
|
||||||
<>
|
<>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
export const AddGithubProvider = () => {
|
export const AddGithubProvider = () => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
const { data: activeOrganization } = api.organization.active.useQuery();
|
||||||
const { data: session } = authClient.useSession();
|
|
||||||
|
const { data: session } = api.user.session.useQuery();
|
||||||
const { data } = api.user.get.useQuery();
|
const { data } = api.user.get.useQuery();
|
||||||
const [manifest, setManifest] = useState("");
|
const [manifest, setManifest] = useState("");
|
||||||
const [isOrganization, setIsOrganization] = useState(false);
|
const [isOrganization, setIsOrganization] = useState(false);
|
||||||
@@ -30,7 +30,7 @@ export const AddGithubProvider = () => {
|
|||||||
const url = document.location.origin;
|
const url = document.location.origin;
|
||||||
const manifest = JSON.stringify(
|
const manifest = JSON.stringify(
|
||||||
{
|
{
|
||||||
redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id}&userId=${session?.user?.id}`,
|
redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id ?? ""}&userId=${session?.user?.id ?? ""}`,
|
||||||
name: `Dokploy-${format(new Date(), "yyyy-MM-dd")}-${randomString()}`,
|
name: `Dokploy-${format(new Date(), "yyyy-MM-dd")}-${randomString()}`,
|
||||||
url: origin,
|
url: origin,
|
||||||
hook_attributes: {
|
hook_attributes: {
|
||||||
@@ -52,7 +52,7 @@ export const AddGithubProvider = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
setManifest(manifest);
|
setManifest(manifest);
|
||||||
}, [data?.id]);
|
}, [activeOrganization?.id, session?.user?.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
@@ -98,8 +98,8 @@ export const AddGithubProvider = () => {
|
|||||||
<form
|
<form
|
||||||
action={
|
action={
|
||||||
isOrganization
|
isOrganization
|
||||||
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}`
|
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
|
||||||
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}`
|
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
|
||||||
}
|
}
|
||||||
method="post"
|
method="post"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export const SetupMonitoring = ({ serverId }: Props) => {
|
|||||||
|
|
||||||
const url = useUrl();
|
const url = useUrl();
|
||||||
|
|
||||||
const { data: projects } = api.project.all.useQuery();
|
const { data: projects } = api.project.allForPermissions.useQuery();
|
||||||
|
|
||||||
const extractServicesFromProjects = () => {
|
const extractServicesFromProjects = () => {
|
||||||
if (!projects) return [];
|
if (!projects) return [];
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export const AddInvitation = () => {
|
|||||||
api.notification.getEmailProviders.useQuery();
|
api.notification.getEmailProviders.useQuery();
|
||||||
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
|
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
const { data: activeOrganization } = api.organization.active.useQuery();
|
||||||
|
|
||||||
const form = useForm<AddInvitation>({
|
const form = useForm<AddInvitation>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
|||||||
@@ -28,8 +28,12 @@ import {
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api, type RouterOutputs } from "@/utils/api";
|
import { api, type RouterOutputs } from "@/utils/api";
|
||||||
|
|
||||||
type Project = RouterOutputs["project"]["all"][number];
|
/** Shape returned by project.allForPermissions (admin only). Used for the permissions UI. */
|
||||||
type Environment = Project["environments"][number];
|
type ProjectForPermissions =
|
||||||
|
RouterOutputs["project"]["allForPermissions"][number];
|
||||||
|
type EnvironmentForPermissions = ProjectForPermissions["environments"][number];
|
||||||
|
|
||||||
|
type Environment = EnvironmentForPermissions;
|
||||||
|
|
||||||
export type Services = {
|
export type Services = {
|
||||||
appName: string;
|
appName: string;
|
||||||
@@ -173,7 +177,9 @@ interface Props {
|
|||||||
|
|
||||||
export const AddUserPermissions = ({ userId }: Props) => {
|
export const AddUserPermissions = ({ userId }: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { data: projects } = api.project.all.useQuery();
|
const { data: projects } = api.project.allForPermissions.useQuery(undefined, {
|
||||||
|
enabled: isOpen,
|
||||||
|
});
|
||||||
|
|
||||||
const { data, refetch } = api.user.one.useQuery(
|
const { data, refetch } = api.user.one.useQuery(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const ShowUsers = () => {
|
|||||||
const { mutateAsync } = api.user.remove.useMutation();
|
const { mutateAsync } = api.user.remove.useMutation();
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { data: session } = authClient.useSession();
|
const { data: session } = api.user.session.useQuery();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||||
import { GithubIcon } from "../icons/data-tools-icons";
|
import { GithubIcon } from "../icons/data-tools-icons";
|
||||||
import { Logo } from "../shared/logo";
|
import { Logo } from "../shared/logo";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
@@ -9,23 +10,28 @@ interface Props {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
export const OnboardingLayout = ({ children }: Props) => {
|
export const OnboardingLayout = ({ children }: Props) => {
|
||||||
|
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||||
|
const appName = whitelabeling?.appName || "Dokploy";
|
||||||
|
const appDescription =
|
||||||
|
whitelabeling?.appDescription ||
|
||||||
|
"\u201CThe Open Source alternative to Netlify, Vercel, Heroku.\u201D";
|
||||||
|
const logoUrl =
|
||||||
|
whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container relative min-h-svh flex-col items-center justify-center flex lg:max-w-none lg:grid lg:grid-cols-2 lg:px-0 w-full">
|
<div className="container relative min-h-svh flex-col items-center justify-center flex lg:max-w-none lg:grid lg:grid-cols-2 lg:px-0 w-full">
|
||||||
<div className="relative hidden h-full flex-col p-10 text-primary dark:border-r lg:flex">
|
<div className="relative hidden h-full flex-col p-10 text-primary dark:border-r lg:flex">
|
||||||
<div className="absolute inset-0 bg-muted" />
|
<div className="absolute inset-0 bg-muted" />
|
||||||
<Link
|
<Link
|
||||||
href="https://dokploy.com"
|
href="/"
|
||||||
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
|
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
|
||||||
>
|
>
|
||||||
<Logo className="size-10" />
|
<Logo className="size-10" logoUrl={logoUrl} />
|
||||||
Dokploy
|
{appName}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="relative z-20 mt-auto">
|
<div className="relative z-20 mt-auto">
|
||||||
<blockquote className="space-y-2">
|
<blockquote className="space-y-2">
|
||||||
<p className="text-lg text-primary">
|
<p className="text-lg text-primary">{appDescription}</p>
|
||||||
“The Open Source alternative to Netlify, Vercel,
|
|
||||||
Heroku.”
|
|
||||||
</p>
|
|
||||||
</blockquote>
|
</blockquote>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ import {
|
|||||||
LogIn,
|
LogIn,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
Package,
|
Package,
|
||||||
|
Palette,
|
||||||
PieChart,
|
PieChart,
|
||||||
|
Rocket,
|
||||||
Server,
|
Server,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Star,
|
Star,
|
||||||
@@ -145,6 +147,12 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/projects",
|
url: "/dashboard/projects",
|
||||||
icon: Folder,
|
icon: Folder,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
isSingle: true,
|
||||||
|
title: "Deployments",
|
||||||
|
url: "/dashboard/deployments",
|
||||||
|
icon: Rocket,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
title: "Monitoring",
|
title: "Monitoring",
|
||||||
@@ -415,6 +423,14 @@ const MENU: Menu = {
|
|||||||
isEnabled: ({ auth }) =>
|
isEnabled: ({ auth }) =>
|
||||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
isSingle: true,
|
||||||
|
title: "Whitelabeling",
|
||||||
|
url: "/dashboard/settings/whitelabeling",
|
||||||
|
icon: Palette,
|
||||||
|
// Only enabled for owners in non-cloud environments (enterprise)
|
||||||
|
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
help: [
|
help: [
|
||||||
@@ -438,38 +454,39 @@ const MENU: Menu = {
|
|||||||
function createMenuForAuthUser(opts: {
|
function createMenuForAuthUser(opts: {
|
||||||
auth?: AuthQueryOutput;
|
auth?: AuthQueryOutput;
|
||||||
isCloud: boolean;
|
isCloud: boolean;
|
||||||
|
whitelabeling?: {
|
||||||
|
docsUrl?: string | null;
|
||||||
|
supportUrl?: string | null;
|
||||||
|
} | null;
|
||||||
}): Menu {
|
}): Menu {
|
||||||
|
const filterEnabled = <
|
||||||
|
T extends {
|
||||||
|
isEnabled?: (o: { auth?: AuthQueryOutput; isCloud: boolean }) => boolean;
|
||||||
|
},
|
||||||
|
>(
|
||||||
|
items: readonly T[],
|
||||||
|
): T[] =>
|
||||||
|
items.filter((item) =>
|
||||||
|
!item.isEnabled
|
||||||
|
? true
|
||||||
|
: item.isEnabled({ auth: opts.auth, isCloud: opts.isCloud }),
|
||||||
|
) as T[];
|
||||||
|
|
||||||
|
// Apply whitelabeling URL overrides to help items
|
||||||
|
const helpItems = filterEnabled(MENU.help).map((item) => {
|
||||||
|
if (opts.whitelabeling?.docsUrl && item.name === "Documentation") {
|
||||||
|
return { ...item, url: opts.whitelabeling.docsUrl };
|
||||||
|
}
|
||||||
|
if (opts.whitelabeling?.supportUrl && item.name === "Support") {
|
||||||
|
return { ...item, url: opts.whitelabeling.supportUrl };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Filter the home items based on the user's role and permissions
|
home: filterEnabled(MENU.home),
|
||||||
// Calls the `isEnabled` function if it exists to determine if the item should be displayed
|
settings: filterEnabled(MENU.settings),
|
||||||
home: MENU.home.filter((item) =>
|
help: helpItems,
|
||||||
!item.isEnabled
|
|
||||||
? true
|
|
||||||
: item.isEnabled({
|
|
||||||
auth: opts.auth,
|
|
||||||
isCloud: opts.isCloud,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
// Filter the settings items based on the user's role and permissions
|
|
||||||
// Calls the `isEnabled` function if it exists to determine if the item should be displayed
|
|
||||||
settings: MENU.settings.filter((item) =>
|
|
||||||
!item.isEnabled
|
|
||||||
? true
|
|
||||||
: item.isEnabled({
|
|
||||||
auth: opts.auth,
|
|
||||||
isCloud: opts.isCloud,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
// Filter the help items based on the user's role and permissions
|
|
||||||
// Calls the `isEnabled` function if it exists to determine if the item should be displayed
|
|
||||||
help: MENU.help.filter((item) =>
|
|
||||||
!item.isEnabled
|
|
||||||
? true
|
|
||||||
: item.isEnabled({
|
|
||||||
auth: opts.auth,
|
|
||||||
isCloud: opts.isCloud,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,7 +556,7 @@ function SidebarLogo() {
|
|||||||
const { state } = useSidebar();
|
const { state } = useSidebar();
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
const { data: user } = api.user.get.useQuery();
|
const { data: user } = api.user.get.useQuery();
|
||||||
const { data: session } = authClient.useSession();
|
const { data: session } = api.user.session.useQuery();
|
||||||
const {
|
const {
|
||||||
data: organizations,
|
data: organizations,
|
||||||
refetch,
|
refetch,
|
||||||
@@ -550,8 +567,7 @@ function SidebarLogo() {
|
|||||||
const { mutateAsync: setDefaultOrganization, isPending: isSettingDefault } =
|
const { mutateAsync: setDefaultOrganization, isPending: isSettingDefault } =
|
||||||
api.organization.setDefault.useMutation();
|
api.organization.setDefault.useMutation();
|
||||||
const { isMobile } = useSidebar();
|
const { isMobile } = useSidebar();
|
||||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
const { data: activeOrganization } = api.organization.active.useQuery();
|
||||||
const _utils = api.useUtils();
|
|
||||||
|
|
||||||
const { data: invitations, refetch: refetchInvitations } =
|
const { data: invitations, refetch: refetchInvitations } =
|
||||||
api.user.getInvitations.useQuery();
|
api.user.getInvitations.useQuery();
|
||||||
@@ -879,6 +895,10 @@ export default function Page({ children }: Props) {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
|
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
|
||||||
|
const { data: whitelabeling } = api.whitelabeling.get.useQuery(undefined, {
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
const includesProjects = pathname?.includes("/dashboard/project");
|
const includesProjects = pathname?.includes("/dashboard/project");
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
@@ -887,7 +907,7 @@ export default function Page({ children }: Props) {
|
|||||||
home: filteredHome,
|
home: filteredHome,
|
||||||
settings: filteredSettings,
|
settings: filteredSettings,
|
||||||
help,
|
help,
|
||||||
} = createMenuForAuthUser({ auth, isCloud: !!isCloud });
|
} = createMenuForAuthUser({ auth, isCloud: !!isCloud, whitelabeling });
|
||||||
|
|
||||||
const activeItem = findActiveNavItem(
|
const activeItem = findActiveNavItem(
|
||||||
[...filteredHome, ...filteredSettings],
|
[...filteredHome, ...filteredSettings],
|
||||||
@@ -1135,6 +1155,11 @@ export default function Page({ children }: Props) {
|
|||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<UserNav />
|
<UserNav />
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
{whitelabeling?.footerText && (
|
||||||
|
<div className="px-3 text-xs text-muted-foreground text-center group-data-[collapsible=icon]:hidden">
|
||||||
|
{whitelabeling.footerText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{dokployVersion && (
|
{dokployVersion && (
|
||||||
<>
|
<>
|
||||||
<div className="px-3 text-xs text-muted-foreground text-center group-data-[collapsible=icon]:hidden">
|
<div className="px-3 text-xs text-muted-foreground text-center group-data-[collapsible=icon]:hidden">
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface WhitelabelingPreviewProps {
|
||||||
|
config: {
|
||||||
|
appName?: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
footerText?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) {
|
||||||
|
const appName = config.appName || "Dokploy";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-transparent">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Live Preview</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
A quick preview of how your branding changes will look.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-lg border overflow-hidden">
|
||||||
|
{/* Simulated sidebar header */}
|
||||||
|
<div className="flex items-center gap-3 p-4 border-b bg-sidebar">
|
||||||
|
{config.logoUrl ? (
|
||||||
|
<img
|
||||||
|
src={config.logoUrl}
|
||||||
|
alt="Preview Logo"
|
||||||
|
className="size-8 rounded-sm object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="size-8 rounded-sm flex items-center justify-center bg-primary text-primary-foreground font-bold text-sm">
|
||||||
|
{appName.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="font-semibold text-sm">{appName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Simulated content area */}
|
||||||
|
<div className="p-4 bg-background">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="h-2 w-16 rounded-full bg-primary" />
|
||||||
|
<div className="h-2 w-24 rounded-full bg-muted" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="px-3 py-1.5 rounded-md text-xs bg-primary text-primary-foreground font-medium">
|
||||||
|
Button
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1.5 rounded-md text-xs border font-medium">
|
||||||
|
Secondary
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Simulated footer */}
|
||||||
|
{config.footerText && (
|
||||||
|
<div className="px-4 py-2 border-t text-xs text-muted-foreground text-center bg-sidebar">
|
||||||
|
{config.footerText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Head from "next/head";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export function WhitelabelingProvider() {
|
||||||
|
const { data: config } = api.whitelabeling.getPublic.useQuery(undefined, {
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
{config.metaTitle && <title>{config.metaTitle}</title>}
|
||||||
|
{config.faviconUrl && <link rel="icon" href={config.faviconUrl} />}
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
{config.customCss && (
|
||||||
|
<style
|
||||||
|
id="whitelabeling-styles"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: config.customCss,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,589 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import { Loader2, RotateCcw } from "lucide-react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
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";
|
||||||
|
import { WhitelabelingPreview } from "./whitelabeling-preview";
|
||||||
|
|
||||||
|
const safeUrlField = z
|
||||||
|
.string()
|
||||||
|
.refine((val) => val === "" || /^https?:\/\//i.test(val), {
|
||||||
|
message: "Only http:// and https:// URLs are allowed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
appName: z.string(),
|
||||||
|
appDescription: z.string(),
|
||||||
|
logoUrl: safeUrlField,
|
||||||
|
faviconUrl: safeUrlField,
|
||||||
|
customCss: z.string(),
|
||||||
|
loginLogoUrl: safeUrlField,
|
||||||
|
supportUrl: safeUrlField,
|
||||||
|
docsUrl: safeUrlField,
|
||||||
|
errorPageTitle: z.string(),
|
||||||
|
errorPageDescription: z.string(),
|
||||||
|
metaTitle: z.string(),
|
||||||
|
footerText: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormSchema = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const DEFAULT_CSS_TEMPLATE = `/* ============================================
|
||||||
|
Dokploy Default Theme - CSS Variables
|
||||||
|
Modify these values to customize your instance.
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* ---------- Light Mode ---------- */
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 240 10% 3.9%;
|
||||||
|
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 240 10% 3.9%;
|
||||||
|
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 240 10% 3.9%;
|
||||||
|
|
||||||
|
--primary: 240 5.9% 10%;
|
||||||
|
--primary-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--secondary: 240 4.8% 95.9%;
|
||||||
|
--secondary-foreground: 240 5.9% 10%;
|
||||||
|
|
||||||
|
--muted: 240 4.8% 95.9%;
|
||||||
|
--muted-foreground: 240 3.8% 46.1%;
|
||||||
|
|
||||||
|
--accent: 240 4.8% 95.9%;
|
||||||
|
--accent-foreground: 240 5.9% 10%;
|
||||||
|
|
||||||
|
--destructive: 0 84.2% 50.2%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--border: 240 5.9% 90%;
|
||||||
|
--input: 240 5.9% 90%;
|
||||||
|
--ring: 240 10% 3.9%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
--sidebar-background: 0 0% 98%;
|
||||||
|
--sidebar-foreground: 240 5.3% 26.1%;
|
||||||
|
--sidebar-primary: 240 5.9% 10%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 98%;
|
||||||
|
--sidebar-accent: 240 4.8% 95.9%;
|
||||||
|
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||||
|
--sidebar-border: 220 13% 91%;
|
||||||
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
|
|
||||||
|
/* Charts */
|
||||||
|
--chart-1: 173 58% 39%;
|
||||||
|
--chart-2: 12 76% 61%;
|
||||||
|
--chart-3: 197 37% 24%;
|
||||||
|
--chart-4: 43 74% 66%;
|
||||||
|
--chart-5: 27 87% 67%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Dark Mode ---------- */
|
||||||
|
.dark {
|
||||||
|
--background: 0 0% 0%;
|
||||||
|
--foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--card: 240 4% 10%;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--popover: 240 10% 3.9%;
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--primary: 0 0% 98%;
|
||||||
|
--primary-foreground: 240 5.9% 10%;
|
||||||
|
|
||||||
|
--secondary: 240 3.7% 15.9%;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--muted: 240 4% 10%;
|
||||||
|
--muted-foreground: 240 5% 64.9%;
|
||||||
|
|
||||||
|
--accent: 240 3.7% 15.9%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--destructive: 0 84.2% 50.2%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
|
||||||
|
--border: 240 3.7% 15.9%;
|
||||||
|
--input: 240 4% 10%;
|
||||||
|
--ring: 240 4.9% 83.9%;
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
--sidebar-background: 240 5.9% 10%;
|
||||||
|
--sidebar-foreground: 240 4.8% 95.9%;
|
||||||
|
--sidebar-primary: 224.3 76.3% 48%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 240 3.7% 15.9%;
|
||||||
|
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||||
|
--sidebar-border: 240 3.7% 15.9%;
|
||||||
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
|
|
||||||
|
/* Charts */
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
|
--chart-2: 340 75% 55%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 160 60% 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Custom Styles ---------- */
|
||||||
|
/* Add your own CSS rules below */
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function WhitelabelingSettings() {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isPending: isLoading,
|
||||||
|
refetch,
|
||||||
|
} = api.whitelabeling.get.useQuery();
|
||||||
|
|
||||||
|
const { mutateAsync: updateWhitelabeling, isPending: isUpdating } =
|
||||||
|
api.whitelabeling.update.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: resetWhitelabeling, isPending: isResetting } =
|
||||||
|
api.whitelabeling.reset.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<FormSchema>({
|
||||||
|
defaultValues: {
|
||||||
|
appName: "",
|
||||||
|
appDescription: "",
|
||||||
|
logoUrl: "",
|
||||||
|
faviconUrl: "",
|
||||||
|
customCss: "",
|
||||||
|
loginLogoUrl: "",
|
||||||
|
supportUrl: "",
|
||||||
|
docsUrl: "",
|
||||||
|
errorPageTitle: "",
|
||||||
|
errorPageDescription: "",
|
||||||
|
metaTitle: "",
|
||||||
|
footerText: "",
|
||||||
|
},
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
form.reset({
|
||||||
|
appName: data.appName ?? "",
|
||||||
|
appDescription: data.appDescription ?? "",
|
||||||
|
logoUrl: data.logoUrl ?? "",
|
||||||
|
faviconUrl: data.faviconUrl ?? "",
|
||||||
|
customCss: data.customCss ?? "",
|
||||||
|
loginLogoUrl: data.loginLogoUrl ?? "",
|
||||||
|
supportUrl: data.supportUrl ?? "",
|
||||||
|
docsUrl: data.docsUrl ?? "",
|
||||||
|
errorPageTitle: data.errorPageTitle ?? "",
|
||||||
|
errorPageDescription: data.errorPageDescription ?? "",
|
||||||
|
metaTitle: data.metaTitle ?? "",
|
||||||
|
footerText: data.footerText ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
|
||||||
|
<Loader2 className="size-6 text-muted-foreground animate-spin" />
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Loading whitelabeling settings...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async (values: FormSchema) => {
|
||||||
|
await updateWhitelabeling({
|
||||||
|
whitelabelingConfig: {
|
||||||
|
appName: values.appName || null,
|
||||||
|
appDescription: values.appDescription || null,
|
||||||
|
logoUrl: values.logoUrl || null,
|
||||||
|
faviconUrl: values.faviconUrl || null,
|
||||||
|
customCss: values.customCss || null,
|
||||||
|
loginLogoUrl: values.loginLogoUrl || null,
|
||||||
|
supportUrl: values.supportUrl || null,
|
||||||
|
docsUrl: values.docsUrl || null,
|
||||||
|
errorPageTitle: values.errorPageTitle || null,
|
||||||
|
errorPageDescription: values.errorPageDescription || null,
|
||||||
|
metaTitle: values.metaTitle || null,
|
||||||
|
footerText: values.footerText || null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success("Whitelabeling settings updated");
|
||||||
|
await refetch();
|
||||||
|
await utils.whitelabeling.getPublic.invalidate();
|
||||||
|
await utils.whitelabeling.get.invalidate();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
toast.error(
|
||||||
|
error?.message || "Failed to update whitelabeling settings",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
await resetWhitelabeling()
|
||||||
|
.then(async () => {
|
||||||
|
toast.success("Whitelabeling settings reset to defaults");
|
||||||
|
await refetch();
|
||||||
|
await utils.whitelabeling.getPublic.invalidate();
|
||||||
|
await utils.whitelabeling.get.invalidate();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
toast.error(error?.message || "Failed to reset whitelabeling settings");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="flex flex-col gap-6"
|
||||||
|
>
|
||||||
|
{/* Branding Section */}
|
||||||
|
<Card className="bg-transparent">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Branding</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Customize the application name, logos, and favicon to match your
|
||||||
|
brand identity.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="appName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Application Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Dokploy" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Replaces "Dokploy" across the entire interface.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="appDescription"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Application Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="The Open Source alternative to Netlify, Vercel, Heroku."
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Tagline shown on the login/onboarding pages. Defaults to
|
||||||
|
the standard Dokploy description if empty.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="logoUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Logo URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com/logo.svg"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Main logo shown in the sidebar and header. Recommended
|
||||||
|
size: 128x128px.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="loginLogoUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Login Page Logo URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com/login-logo.svg"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Logo displayed on the login page. If empty, the main logo
|
||||||
|
is used.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="faviconUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Favicon URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com/favicon.ico"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Browser tab icon. Supports .ico, .png, and .svg formats.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Appearance Section */}
|
||||||
|
<Card className="bg-transparent">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Appearance</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Customize the look and feel of the application with custom CSS.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="customCss"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FormLabel>Custom CSS</FormLabel>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
form.setValue("customCss", DEFAULT_CSS_TEMPLATE);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Load Default Styles
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<div className="max-h-[350px] overflow-auto">
|
||||||
|
<CodeEditor
|
||||||
|
language="css"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
placeholder="/* Click 'Load Default Styles' to start with the base theme variables */"
|
||||||
|
lineWrapping
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Inject custom CSS styles globally. Click "Load Default
|
||||||
|
Styles" to get the base theme CSS variables as a starting
|
||||||
|
point.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Metadata & Links Section */}
|
||||||
|
<Card className="bg-transparent">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Metadata & Links</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Customize the page title, footer text, and sidebar links.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metaTitle"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Page Title</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Dokploy" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Browser tab title. Defaults to "Dokploy" if empty.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="footerText"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Footer Text</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Powered by Your Company" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Custom text displayed in the footer area.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="supportUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Support URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="https://support.example.com"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Custom URL for the "Support" link in the sidebar.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="docsUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Documentation URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="https://docs.example.com"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Custom URL for the "Documentation" link in the sidebar.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Error Pages Section */}
|
||||||
|
<Card className="bg-transparent">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Error Pages</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Customize the error page messages shown to users.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="errorPageTitle"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Error Page Title</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Something went wrong" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="errorPageDescription"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Error Page Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="We're sorry, but an unexpected error occurred. Please try again later."
|
||||||
|
className="min-h-[80px]"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<DialogAction
|
||||||
|
title="Reset Whitelabeling"
|
||||||
|
description="Are you sure you want to reset all whitelabeling settings to their defaults? This action cannot be undone."
|
||||||
|
type="destructive"
|
||||||
|
onClick={handleReset}
|
||||||
|
>
|
||||||
|
<Button variant="outline" type="button" isLoading={isResetting}>
|
||||||
|
<RotateCcw className="size-4 mr-2" />
|
||||||
|
Reset to Defaults
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
|
||||||
|
<Button type="submit" isLoading={isUpdating} disabled={isUpdating}>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{/* Live Preview */}
|
||||||
|
<WhitelabelingPreview config={form.watch()} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,7 +32,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const BreadcrumbSidebar = ({ list }: Props) => {
|
export const BreadcrumbSidebar = ({ list }: Props) => {
|
||||||
console.log(list);
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
|
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import {
|
|||||||
type CompletionContext,
|
type CompletionContext,
|
||||||
type CompletionResult,
|
type CompletionResult,
|
||||||
} from "@codemirror/autocomplete";
|
} from "@codemirror/autocomplete";
|
||||||
|
import { css } from "@codemirror/lang-css";
|
||||||
import { json } from "@codemirror/lang-json";
|
import { json } from "@codemirror/lang-json";
|
||||||
import { yaml } from "@codemirror/lang-yaml";
|
import { yaml } from "@codemirror/lang-yaml";
|
||||||
import { StreamLanguage } from "@codemirror/language";
|
import { StreamLanguage } from "@codemirror/language";
|
||||||
import { properties } from "@codemirror/legacy-modes/mode/properties";
|
import { properties } from "@codemirror/legacy-modes/mode/properties";
|
||||||
import { shell } from "@codemirror/legacy-modes/mode/shell";
|
import { shell } from "@codemirror/legacy-modes/mode/shell";
|
||||||
import { EditorView } from "@codemirror/view";
|
import { search, searchKeymap } from "@codemirror/search";
|
||||||
|
import { EditorView, keymap } from "@codemirror/view";
|
||||||
import { githubDark, githubLight } from "@uiw/codemirror-theme-github";
|
import { githubDark, githubLight } from "@uiw/codemirror-theme-github";
|
||||||
import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
@@ -130,7 +132,7 @@ function dockerComposeComplete(
|
|||||||
interface Props extends ReactCodeMirrorProps {
|
interface Props extends ReactCodeMirrorProps {
|
||||||
wrapperClassName?: string;
|
wrapperClassName?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
language?: "yaml" | "json" | "properties" | "shell";
|
language?: "yaml" | "json" | "properties" | "shell" | "css";
|
||||||
lineWrapping?: boolean;
|
lineWrapping?: boolean;
|
||||||
lineNumbers?: boolean;
|
lineNumbers?: boolean;
|
||||||
}
|
}
|
||||||
@@ -155,13 +157,17 @@ export const CodeEditor = ({
|
|||||||
}}
|
}}
|
||||||
theme={resolvedTheme === "dark" ? githubDark : githubLight}
|
theme={resolvedTheme === "dark" ? githubDark : githubLight}
|
||||||
extensions={[
|
extensions={[
|
||||||
|
search(),
|
||||||
|
keymap.of(searchKeymap),
|
||||||
language === "yaml"
|
language === "yaml"
|
||||||
? yaml()
|
? yaml()
|
||||||
: language === "json"
|
: language === "json"
|
||||||
? json()
|
? json()
|
||||||
: language === "shell"
|
: language === "css"
|
||||||
? StreamLanguage.define(shell)
|
? css()
|
||||||
: StreamLanguage.define(properties),
|
: language === "shell"
|
||||||
|
? StreamLanguage.define(shell)
|
||||||
|
: StreamLanguage.define(properties),
|
||||||
props.lineWrapping ? EditorView.lineWrapping : [],
|
props.lineWrapping ? EditorView.lineWrapping : [],
|
||||||
language === "yaml"
|
language === "yaml"
|
||||||
? autocompletion({
|
? autocompletion({
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const Command = React.forwardRef<
|
|||||||
<CommandPrimitive
|
<CommandPrimitive
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground p-px",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -44,7 +44,7 @@ const CommandInput = React.forwardRef<
|
|||||||
<CommandPrimitive.Input
|
<CommandPrimitive.Input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 focus-visible:ring-inset",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
1
apps/dokploy/drizzle/0148_futuristic_bullseye.sql
Normal file
1
apps/dokploy/drizzle/0148_futuristic_bullseye.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "webServerSettings" ADD COLUMN "whitelabelingConfig" jsonb DEFAULT '{"appName":null,"appDescription":null,"logoUrl":null,"faviconUrl":null,"customCss":null,"loginLogoUrl":null,"supportUrl":null,"docsUrl":null,"errorPageTitle":null,"errorPageDescription":null,"metaTitle":null,"footerText":null}'::jsonb;
|
||||||
7467
apps/dokploy/drizzle/meta/0148_snapshot.json
Normal file
7467
apps/dokploy/drizzle/meta/0148_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1037,6 +1037,13 @@
|
|||||||
"when": 1771830695385,
|
"when": 1771830695385,
|
||||||
"tag": "0147_right_lake",
|
"tag": "0147_right_lake",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 148,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773129798212,
|
||||||
|
"tag": "0148_futuristic_bullseye",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dokploy",
|
"name": "dokploy",
|
||||||
"version": "v0.28.0",
|
"version": "v0.28.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -39,8 +39,6 @@
|
|||||||
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
|
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"resend": "^6.0.2",
|
|
||||||
"@better-auth/sso": "1.5.0-beta.16",
|
|
||||||
"@ai-sdk/anthropic": "^3.0.44",
|
"@ai-sdk/anthropic": "^3.0.44",
|
||||||
"@ai-sdk/azure": "^3.0.30",
|
"@ai-sdk/azure": "^3.0.30",
|
||||||
"@ai-sdk/cohere": "^3.0.21",
|
"@ai-sdk/cohere": "^3.0.21",
|
||||||
@@ -48,14 +46,17 @@
|
|||||||
"@ai-sdk/mistral": "^3.0.20",
|
"@ai-sdk/mistral": "^3.0.20",
|
||||||
"@ai-sdk/openai": "^3.0.29",
|
"@ai-sdk/openai": "^3.0.29",
|
||||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||||
|
"@better-auth/sso": "1.5.0-beta.16",
|
||||||
"@codemirror/autocomplete": "^6.18.6",
|
"@codemirror/autocomplete": "^6.18.6",
|
||||||
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-json": "^6.0.1",
|
"@codemirror/lang-json": "^6.0.1",
|
||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
"@codemirror/language": "^6.11.0",
|
"@codemirror/language": "^6.11.0",
|
||||||
"@codemirror/legacy-modes": "6.4.0",
|
"@codemirror/legacy-modes": "6.4.0",
|
||||||
"@codemirror/view": "6.29.0",
|
"@codemirror/search": "^6.6.0",
|
||||||
|
"@codemirror/view": "^6.39.15",
|
||||||
"@dokploy/server": "workspace:*",
|
"@dokploy/server": "workspace:*",
|
||||||
"@dokploy/trpc-openapi": "0.0.16",
|
"@dokploy/trpc-openapi": "0.0.17",
|
||||||
"@faker-js/faker": "^8.4.1",
|
"@faker-js/faker": "^8.4.1",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@octokit/auth-app": "^6.1.3",
|
"@octokit/auth-app": "^6.1.3",
|
||||||
@@ -102,7 +103,6 @@
|
|||||||
"bl": "6.0.11",
|
"bl": "6.0.11",
|
||||||
"boxen": "^7.1.1",
|
"boxen": "^7.1.1",
|
||||||
"bullmq": "5.67.3",
|
"bullmq": "5.67.3",
|
||||||
"shell-quote": "^1.8.1",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^0.2.1",
|
"cmdk": "^0.2.1",
|
||||||
@@ -139,6 +139,9 @@
|
|||||||
"react-hook-form": "^7.71.2",
|
"react-hook-form": "^7.71.2",
|
||||||
"react-markdown": "^9.1.0",
|
"react-markdown": "^9.1.0",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
|
"resend": "^6.0.2",
|
||||||
|
"semver": "7.7.3",
|
||||||
|
"shell-quote": "^1.8.1",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"ssh2": "1.15.0",
|
"ssh2": "1.15.0",
|
||||||
@@ -154,12 +157,9 @@
|
|||||||
"xterm-addon-fit": "^0.8.0",
|
"xterm-addon-fit": "^0.8.0",
|
||||||
"yaml": "2.8.1",
|
"yaml": "2.8.1",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
"zod-form-data": "^3.0.1",
|
"zod-form-data": "^3.0.1"
|
||||||
"semver": "7.7.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/semver": "7.7.1",
|
|
||||||
"@types/shell-quote": "^1.7.5",
|
|
||||||
"@types/adm-zip": "^0.5.7",
|
"@types/adm-zip": "^0.5.7",
|
||||||
"@types/bcrypt": "5.0.2",
|
"@types/bcrypt": "5.0.2",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
@@ -171,6 +171,8 @@
|
|||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/react": "^18.3.5",
|
"@types/react": "^18.3.5",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@types/semver": "7.7.1",
|
||||||
|
"@types/shell-quote": "^1.7.5",
|
||||||
"@types/ssh2": "1.15.1",
|
"@types/ssh2": "1.15.1",
|
||||||
"@types/swagger-ui-react": "^4.19.0",
|
"@types/swagger-ui-react": "^4.19.0",
|
||||||
"@types/ws": "8.5.10",
|
"@types/ws": "8.5.10",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ThemeProvider } from "next-themes";
|
|||||||
import NextTopLoader from "nextjs-toploader";
|
import NextTopLoader from "nextjs-toploader";
|
||||||
import type { ReactElement, ReactNode } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
import { SearchCommand } from "@/components/dashboard/search-command";
|
import { SearchCommand } from "@/components/dashboard/search-command";
|
||||||
|
import { WhitelabelingProvider } from "@/components/proprietary/whitelabeling/whitelabeling-provider";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ const MyApp = ({
|
|||||||
forcedTheme={Component.theme}
|
forcedTheme={Component.theme}
|
||||||
>
|
>
|
||||||
<NextTopLoader color="hsl(var(--sidebar-ring))" />
|
<NextTopLoader color="hsl(var(--sidebar-ring))" />
|
||||||
|
<WhitelabelingProvider />
|
||||||
<Toaster richColors />
|
<Toaster richColors />
|
||||||
<SearchCommand />
|
<SearchCommand />
|
||||||
{getLayout(<Component {...pageProps} />)}
|
{getLayout(<Component {...pageProps} />)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { NextPageContext } from "next";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Logo } from "@/components/shared/logo";
|
import { Logo } from "@/components/shared/logo";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
statusCode: number;
|
statusCode: number;
|
||||||
@@ -10,18 +11,20 @@ interface Props {
|
|||||||
|
|
||||||
export default function Custom404({ statusCode, error }: Props) {
|
export default function Custom404({ statusCode, error }: Props) {
|
||||||
const displayStatusCode = statusCode || 400;
|
const displayStatusCode = statusCode || 400;
|
||||||
|
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||||
|
const appName = whitelabeling?.appName || "Dokploy";
|
||||||
|
const logoUrl = whitelabeling?.logoUrl || undefined;
|
||||||
|
const errorTitle = whitelabeling?.errorPageTitle;
|
||||||
|
const errorDescription = whitelabeling?.errorPageDescription;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen">
|
<div className="h-screen">
|
||||||
<div className="max-w-[50rem] flex flex-col mx-auto size-full">
|
<div className="max-w-[50rem] flex flex-col mx-auto size-full">
|
||||||
<header className="mb-auto flex justify-center z-50 w-full py-4">
|
<header className="mb-auto flex justify-center z-50 w-full py-4">
|
||||||
<nav className="px-4 sm:px-6 lg:px-8" aria-label="Global">
|
<nav className="px-4 sm:px-6 lg:px-8" aria-label="Global">
|
||||||
<Link
|
<Link href="/" className="flex flex-row items-center gap-2">
|
||||||
href="https://dokploy.com"
|
<Logo logoUrl={logoUrl} />
|
||||||
target="_blank"
|
<span className="font-medium text-sm">{appName}</span>
|
||||||
className="flex flex-row items-center gap-2"
|
|
||||||
>
|
|
||||||
<Logo />
|
|
||||||
<span className="font-medium text-sm">Dokploy</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
@@ -30,19 +33,18 @@ export default function Custom404({ statusCode, error }: Props) {
|
|||||||
<h1 className="block text-7xl font-bold text-primary sm:text-9xl">
|
<h1 className="block text-7xl font-bold text-primary sm:text-9xl">
|
||||||
{displayStatusCode}
|
{displayStatusCode}
|
||||||
</h1>
|
</h1>
|
||||||
{/* <AlertBlock className="max-w-xs mx-auto">
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Oops, something went wrong.
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Sorry, we couldn't find your page.
|
|
||||||
</p>
|
|
||||||
</AlertBlock> */}
|
|
||||||
<p className="mt-3 text-muted-foreground">
|
<p className="mt-3 text-muted-foreground">
|
||||||
{statusCode === 404
|
{errorTitle
|
||||||
? "Sorry, we couldn't find your page."
|
? errorTitle
|
||||||
: "Oops, something went wrong."}
|
: statusCode === 404
|
||||||
|
? "Sorry, we couldn't find your page."
|
||||||
|
: "Oops, something went wrong."}
|
||||||
</p>
|
</p>
|
||||||
|
{errorDescription && (
|
||||||
|
<p className="mt-2 text-muted-foreground text-sm">
|
||||||
|
{errorDescription}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mt-3 text-red-500">
|
<div className="mt-3 text-red-500">
|
||||||
<p>{error.message}</p>
|
<p>{error.message}</p>
|
||||||
@@ -80,13 +82,17 @@ export default function Custom404({ statusCode, error }: Props) {
|
|||||||
<footer className="mt-auto text-center py-5">
|
<footer className="mt-auto text-center py-5">
|
||||||
<div className="max-w-[85rem] mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-[85rem] mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
<Link
|
{whitelabeling?.footerText ? (
|
||||||
href="https://github.com/Dokploy/dokploy/issues"
|
whitelabeling.footerText
|
||||||
target="_blank"
|
) : (
|
||||||
className="underline hover:text-primary transition-colors"
|
<Link
|
||||||
>
|
href="https://github.com/Dokploy/dokploy/issues"
|
||||||
Submit Log in issue on Github
|
target="_blank"
|
||||||
</Link>
|
className="underline hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
Submit Log in issue on Github
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -358,7 +358,8 @@ export default async function handler(
|
|||||||
const shouldCreateDeployment =
|
const shouldCreateDeployment =
|
||||||
action === "opened" ||
|
action === "opened" ||
|
||||||
action === "synchronize" ||
|
action === "synchronize" ||
|
||||||
action === "reopened";
|
action === "reopened" ||
|
||||||
|
action === "labeled";
|
||||||
|
|
||||||
const repository = githubBody?.repository?.name;
|
const repository = githubBody?.repository?.name;
|
||||||
const deploymentHash = githubBody?.pull_request?.head?.sha;
|
const deploymentHash = githubBody?.pull_request?.head?.sha;
|
||||||
|
|||||||
@@ -10,22 +10,29 @@ type Query = {
|
|||||||
state: string;
|
state: string;
|
||||||
installation_id: string;
|
installation_id: string;
|
||||||
setup_action: string;
|
setup_action: string;
|
||||||
userId: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse,
|
res: NextApiResponse,
|
||||||
) {
|
) {
|
||||||
const { code, state, installation_id, userId }: Query = req.query as Query;
|
const { code, state, installation_id }: Query = req.query as Query;
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return res.status(400).json({ error: "Missing code parameter" });
|
return res.status(400).json({ error: "Missing code parameter" });
|
||||||
}
|
}
|
||||||
const [action, value] = state?.split(":");
|
const [action, ...rest] = state?.split(":");
|
||||||
// Value could be the organizationId or the githubProviderId
|
// For gh_init: rest[0] = organizationId, rest[1] = userId
|
||||||
|
// For gh_setup: rest[0] = githubProviderId
|
||||||
|
|
||||||
if (action === "gh_init") {
|
if (action === "gh_init") {
|
||||||
|
const organizationId = rest[0];
|
||||||
|
const userId = rest[1] || (req.query.userId as string);
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(400).json({ error: "Missing userId parameter" });
|
||||||
|
}
|
||||||
|
|
||||||
const octokit = new Octokit({});
|
const octokit = new Octokit({});
|
||||||
const { data } = await octokit.request(
|
const { data } = await octokit.request(
|
||||||
"POST /app-manifests/{code}/conversions",
|
"POST /app-manifests/{code}/conversions",
|
||||||
@@ -44,7 +51,7 @@ export default async function handler(
|
|||||||
githubWebhookSecret: data.webhook_secret,
|
githubWebhookSecret: data.webhook_secret,
|
||||||
githubPrivateKey: data.pem,
|
githubPrivateKey: data.pem,
|
||||||
},
|
},
|
||||||
value as string,
|
organizationId as string,
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
} else if (action === "gh_setup") {
|
} else if (action === "gh_setup") {
|
||||||
@@ -53,7 +60,7 @@ export default async function handler(
|
|||||||
.set({
|
.set({
|
||||||
githubInstallationId: installation_id,
|
githubInstallationId: installation_id,
|
||||||
})
|
})
|
||||||
.where(eq(github.githubId, value as string))
|
.where(eq(github.githubId, rest[0] as string))
|
||||||
.returning();
|
.returning();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
94
apps/dokploy/pages/dashboard/deployments.tsx
Normal file
94
apps/dokploy/pages/dashboard/deployments.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
|
import { Rocket } from "lucide-react";
|
||||||
|
import type { GetServerSidePropsContext } from "next";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table";
|
||||||
|
import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table";
|
||||||
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
|
const TAB_VALUES = ["deployments", "queue"] as const;
|
||||||
|
type TabValue = (typeof TAB_VALUES)[number];
|
||||||
|
|
||||||
|
function isValidTab(t: string): t is TabValue {
|
||||||
|
return TAB_VALUES.includes(t as TabValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeploymentsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const tab =
|
||||||
|
router.query.tab && isValidTab(router.query.tab as string)
|
||||||
|
? (router.query.tab as TabValue)
|
||||||
|
: "deployments";
|
||||||
|
|
||||||
|
const setTab = (value: string) => {
|
||||||
|
if (!isValidTab(value)) return;
|
||||||
|
router.replace(
|
||||||
|
{ pathname: "/dashboard/deployments", query: { tab: value } },
|
||||||
|
undefined,
|
||||||
|
{ shallow: true },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-8xl mx-auto min-h-[45vh]">
|
||||||
|
<div className="rounded-xl bg-background shadow-md h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<Rocket className="size-5" />
|
||||||
|
Deployments
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
All application and compose deployments in one place.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Tabs value={tab} onValueChange={setTab} className="w-full">
|
||||||
|
<TabsList className="mt-2">
|
||||||
|
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||||
|
<TabsTrigger value="queue">Queue</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="deployments" className="mt-0 pt-4">
|
||||||
|
<ShowDeploymentsTable />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="queue" className="mt-0 pt-4">
|
||||||
|
<ShowQueueTable />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardHeader>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeploymentsPage;
|
||||||
|
|
||||||
|
DeploymentsPage.getLayout = (page: ReactElement) => {
|
||||||
|
return <DashboardLayout>{page}</DashboardLayout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||||
|
const { user } = await validateRequest(ctx.req);
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: true,
|
||||||
|
destination: "/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
props: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ import type {
|
|||||||
InferGetServerSidePropsType,
|
InferGetServerSidePropsType,
|
||||||
} from "next";
|
} from "next";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useRouter } from "next/router";
|
import Link from "next/link";
|
||||||
import { type ReactElement, useEffect, useMemo, useState } from "react";
|
import { type ReactElement, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
@@ -98,9 +98,9 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||||
|
|
||||||
export type Services = {
|
export type Services = {
|
||||||
appName: string;
|
|
||||||
serverId?: string | null;
|
serverId?: string | null;
|
||||||
serverName?: string | null;
|
serverName?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -146,7 +146,6 @@ export const extractServicesFromEnvironment = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
appName: item.appName,
|
|
||||||
name: item.name,
|
name: item.name,
|
||||||
type: "application",
|
type: "application",
|
||||||
id: item.applicationId,
|
id: item.applicationId,
|
||||||
@@ -161,7 +160,6 @@ export const extractServicesFromEnvironment = (
|
|||||||
|
|
||||||
const mariadb: Services[] =
|
const mariadb: Services[] =
|
||||||
environment.mariadb?.map((item) => ({
|
environment.mariadb?.map((item) => ({
|
||||||
appName: item.appName,
|
|
||||||
name: item.name,
|
name: item.name,
|
||||||
type: "mariadb",
|
type: "mariadb",
|
||||||
id: item.mariadbId,
|
id: item.mariadbId,
|
||||||
@@ -174,7 +172,6 @@ export const extractServicesFromEnvironment = (
|
|||||||
|
|
||||||
const postgres: Services[] =
|
const postgres: Services[] =
|
||||||
environment.postgres?.map((item) => ({
|
environment.postgres?.map((item) => ({
|
||||||
appName: item.appName,
|
|
||||||
name: item.name,
|
name: item.name,
|
||||||
type: "postgres",
|
type: "postgres",
|
||||||
id: item.postgresId,
|
id: item.postgresId,
|
||||||
@@ -187,7 +184,6 @@ export const extractServicesFromEnvironment = (
|
|||||||
|
|
||||||
const mongo: Services[] =
|
const mongo: Services[] =
|
||||||
environment.mongo?.map((item) => ({
|
environment.mongo?.map((item) => ({
|
||||||
appName: item.appName,
|
|
||||||
name: item.name,
|
name: item.name,
|
||||||
type: "mongo",
|
type: "mongo",
|
||||||
id: item.mongoId,
|
id: item.mongoId,
|
||||||
@@ -200,7 +196,6 @@ export const extractServicesFromEnvironment = (
|
|||||||
|
|
||||||
const redis: Services[] =
|
const redis: Services[] =
|
||||||
environment.redis?.map((item) => ({
|
environment.redis?.map((item) => ({
|
||||||
appName: item.appName,
|
|
||||||
name: item.name,
|
name: item.name,
|
||||||
type: "redis",
|
type: "redis",
|
||||||
id: item.redisId,
|
id: item.redisId,
|
||||||
@@ -213,7 +208,6 @@ export const extractServicesFromEnvironment = (
|
|||||||
|
|
||||||
const mysql: Services[] =
|
const mysql: Services[] =
|
||||||
environment.mysql?.map((item) => ({
|
environment.mysql?.map((item) => ({
|
||||||
appName: item.appName,
|
|
||||||
name: item.name,
|
name: item.name,
|
||||||
type: "mysql",
|
type: "mysql",
|
||||||
id: item.mysqlId,
|
id: item.mysqlId,
|
||||||
@@ -242,7 +236,6 @@ export const extractServicesFromEnvironment = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
appName: item.appName,
|
|
||||||
name: item.name,
|
name: item.name,
|
||||||
type: "compose",
|
type: "compose",
|
||||||
id: item.composeId,
|
id: item.composeId,
|
||||||
@@ -366,7 +359,6 @@ const EnvironmentPage = (
|
|||||||
environmentId,
|
environmentId,
|
||||||
});
|
});
|
||||||
const { data: allProjects } = api.project.all.useQuery();
|
const { data: allProjects } = api.project.all.useQuery();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [isMoveDialogOpen, setIsMoveDialogOpen] = useState(false);
|
const [isMoveDialogOpen, setIsMoveDialogOpen] = useState(false);
|
||||||
const [selectedTargetProject, setSelectedTargetProject] =
|
const [selectedTargetProject, setSelectedTargetProject] =
|
||||||
@@ -379,6 +371,8 @@ const EnvironmentPage = (
|
|||||||
{ projectId: selectedTargetProject },
|
{ projectId: selectedTargetProject },
|
||||||
{ enabled: !!selectedTargetProject },
|
{ enabled: !!selectedTargetProject },
|
||||||
);
|
);
|
||||||
|
const { config: whitelabeling } = useWhitelabeling();
|
||||||
|
const appName = whitelabeling?.appName || "Dokploy";
|
||||||
|
|
||||||
const emptyServices =
|
const emptyServices =
|
||||||
!currentEnvironment ||
|
!currentEnvironment ||
|
||||||
@@ -420,6 +414,7 @@ const EnvironmentPage = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleServiceSelect = (serviceId: string, event: React.MouseEvent) => {
|
const handleServiceSelect = (serviceId: string, event: React.MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setSelectedServices((prev) =>
|
setSelectedServices((prev) =>
|
||||||
prev.includes(serviceId)
|
prev.includes(serviceId)
|
||||||
@@ -785,7 +780,7 @@ const EnvironmentPage = (
|
|||||||
}
|
}
|
||||||
if (success > 0) {
|
if (success > 0) {
|
||||||
toast.success(
|
toast.success(
|
||||||
`${success} service${success !== 1 ? "s" : ""} deployed successfully`,
|
`${success} service${success !== 1 ? "s" : ""} queued for deployment`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (failed > 0) {
|
if (failed > 0) {
|
||||||
@@ -879,7 +874,8 @@ const EnvironmentPage = (
|
|||||||
/>
|
/>
|
||||||
<Head>
|
<Head>
|
||||||
<title>
|
<title>
|
||||||
Environment: {currentEnvironment.name} | {projectData?.name} | Dokploy
|
Environment: {currentEnvironment.name} | {projectData?.name} |{" "}
|
||||||
|
{appName}
|
||||||
</title>
|
</title>
|
||||||
</Head>
|
</Head>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@@ -1471,101 +1467,99 @@ const EnvironmentPage = (
|
|||||||
<div className="flex w-full flex-col gap-4">
|
<div className="flex w-full flex-col gap-4">
|
||||||
<div className="gap-5 pb-10 grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
<div className="gap-5 pb-10 grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
{filteredServices?.map((service) => (
|
{filteredServices?.map((service) => (
|
||||||
<Card
|
<Link
|
||||||
key={service.id}
|
key={service.id}
|
||||||
onClick={() => {
|
href={`/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}`}
|
||||||
router.push(
|
className="block"
|
||||||
`/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border"
|
|
||||||
>
|
>
|
||||||
{service.serverId && (
|
<Card className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border">
|
||||||
<div className="absolute -left-1 -top-2">
|
{service.serverId && (
|
||||||
<ServerIcon className="size-4 text-muted-foreground" />
|
<div className="absolute -left-1 -top-2">
|
||||||
</div>
|
<ServerIcon className="size-4 text-muted-foreground" />
|
||||||
)}
|
|
||||||
<div className="absolute -right-1 -top-2">
|
|
||||||
<StatusTooltip status={service.status} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute -left-3 -bottom-3 size-9 translate-y-1 rounded-full p-0 transition-all duration-200 z-10 bg-background border",
|
|
||||||
selectedServices.includes(service.id)
|
|
||||||
? "opacity-100 translate-y-0"
|
|
||||||
: "opacity-0 group-hover:translate-y-0 group-hover:opacity-100",
|
|
||||||
)}
|
|
||||||
onClick={(e) =>
|
|
||||||
handleServiceSelect(service.id, e)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="h-full w-full flex items-center justify-center">
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedServices.includes(
|
|
||||||
service.id,
|
|
||||||
)}
|
|
||||||
className="data-[state=checked]:bg-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center justify-between">
|
|
||||||
<div className="flex flex-row items-center gap-2 justify-between w-full">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span className="text-base flex items-center gap-2 font-medium leading-none flex-wrap">
|
|
||||||
{service.name}
|
|
||||||
</span>
|
|
||||||
{service.description && (
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">
|
|
||||||
{service.description}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="text-sm font-medium text-muted-foreground self-start">
|
|
||||||
{service.type === "postgres" && (
|
|
||||||
<PostgresqlIcon className="h-7 w-7" />
|
|
||||||
)}
|
|
||||||
{service.type === "redis" && (
|
|
||||||
<RedisIcon className="h-7 w-7" />
|
|
||||||
)}
|
|
||||||
{service.type === "mariadb" && (
|
|
||||||
<MariadbIcon className="h-7 w-7" />
|
|
||||||
)}
|
|
||||||
{service.type === "mongo" && (
|
|
||||||
<MongodbIcon className="h-7 w-7" />
|
|
||||||
)}
|
|
||||||
{service.type === "mysql" && (
|
|
||||||
<MysqlIcon className="h-7 w-7" />
|
|
||||||
)}
|
|
||||||
{service.type === "application" && (
|
|
||||||
<GlobeIcon className="h-6 w-6" />
|
|
||||||
)}
|
|
||||||
{service.type === "compose" && (
|
|
||||||
<CircuitBoard className="h-6 w-6" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</CardTitle>
|
)}
|
||||||
</CardHeader>
|
<div className="absolute -right-1 -top-2">
|
||||||
<CardFooter className="mt-auto">
|
<StatusTooltip status={service.status} />
|
||||||
<div className="space-y-1 text-sm w-full">
|
</div>
|
||||||
{service.serverName && (
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
|
<div
|
||||||
<ServerIcon className="size-3" />
|
className={cn(
|
||||||
<span className="truncate">
|
"absolute -left-3 -bottom-3 size-9 translate-y-1 rounded-full p-0 transition-all duration-200 z-10 bg-background border",
|
||||||
{service.serverName}
|
selectedServices.includes(service.id)
|
||||||
|
? "opacity-100 translate-y-0"
|
||||||
|
: "opacity-0 group-hover:translate-y-0 group-hover:opacity-100",
|
||||||
|
)}
|
||||||
|
onClick={(e) =>
|
||||||
|
handleServiceSelect(service.id, e)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="h-full w-full flex items-center justify-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedServices.includes(
|
||||||
|
service.id,
|
||||||
|
)}
|
||||||
|
className="data-[state=checked]:bg-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-row items-center gap-2 justify-between w-full">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-base flex items-center gap-2 font-medium leading-none flex-wrap">
|
||||||
|
{service.name}
|
||||||
|
</span>
|
||||||
|
{service.description && (
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
{service.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-sm font-medium text-muted-foreground self-start">
|
||||||
|
{service.type === "postgres" && (
|
||||||
|
<PostgresqlIcon className="h-7 w-7" />
|
||||||
|
)}
|
||||||
|
{service.type === "redis" && (
|
||||||
|
<RedisIcon className="h-7 w-7" />
|
||||||
|
)}
|
||||||
|
{service.type === "mariadb" && (
|
||||||
|
<MariadbIcon className="h-7 w-7" />
|
||||||
|
)}
|
||||||
|
{service.type === "mongo" && (
|
||||||
|
<MongodbIcon className="h-7 w-7" />
|
||||||
|
)}
|
||||||
|
{service.type === "mysql" && (
|
||||||
|
<MysqlIcon className="h-7 w-7" />
|
||||||
|
)}
|
||||||
|
{service.type === "application" && (
|
||||||
|
<GlobeIcon className="h-6 w-6" />
|
||||||
|
)}
|
||||||
|
{service.type === "compose" && (
|
||||||
|
<CircuitBoard className="h-6 w-6" />
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</CardTitle>
|
||||||
<DateTooltip date={service.createdAt}>
|
</CardHeader>
|
||||||
Created
|
<CardFooter className="mt-auto">
|
||||||
</DateTooltip>
|
<div className="space-y-1 text-sm w-full">
|
||||||
</div>
|
{service.serverName && (
|
||||||
</CardFooter>
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
|
||||||
</Card>
|
<ServerIcon className="size-3" />
|
||||||
|
<span className="truncate">
|
||||||
|
{service.serverName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DateTooltip date={service.createdAt}>
|
||||||
|
Created
|
||||||
|
</DateTooltip>
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
|
import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||||
|
|
||||||
type TabState =
|
type TabState =
|
||||||
| "projects"
|
| "projects"
|
||||||
@@ -95,6 +96,8 @@ const Service = (
|
|||||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
projectId: data?.environment?.project?.projectId || "",
|
projectId: data?.environment?.project?.projectId || "",
|
||||||
});
|
});
|
||||||
|
const { config: whitelabeling } = useWhitelabeling();
|
||||||
|
const appName = whitelabeling?.appName || "Dokploy";
|
||||||
const environmentDropdownItems =
|
const environmentDropdownItems =
|
||||||
environments?.map((env) => ({
|
environments?.map((env) => ({
|
||||||
name: env.name,
|
name: env.name,
|
||||||
@@ -122,7 +125,8 @@ const Service = (
|
|||||||
/>
|
/>
|
||||||
<Head>
|
<Head>
|
||||||
<title>
|
<title>
|
||||||
Application: {data?.name} - {data?.environment.project.name} | Dokploy
|
Application: {data?.name} - {data?.environment.project.name} |{" "}
|
||||||
|
{appName}
|
||||||
</title>
|
</title>
|
||||||
</Head>
|
</Head>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
|
import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||||
|
|
||||||
type TabState =
|
type TabState =
|
||||||
| "projects"
|
| "projects"
|
||||||
@@ -84,6 +85,8 @@ const Service = (
|
|||||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
projectId: data?.environment?.projectId || "",
|
projectId: data?.environment?.projectId || "",
|
||||||
});
|
});
|
||||||
|
const { config: whitelabeling } = useWhitelabeling();
|
||||||
|
const appName = whitelabeling?.appName || "Dokploy";
|
||||||
const environmentDropdownItems =
|
const environmentDropdownItems =
|
||||||
environments?.map((env) => ({
|
environments?.map((env) => ({
|
||||||
name: env.name,
|
name: env.name,
|
||||||
@@ -111,7 +114,7 @@ const Service = (
|
|||||||
/>
|
/>
|
||||||
<Head>
|
<Head>
|
||||||
<title>
|
<title>
|
||||||
Compose: {data?.name} - {data?.environment?.project?.name} | Dokploy
|
Compose: {data?.name} - {data?.environment?.project?.name} | {appName}
|
||||||
</title>
|
</title>
|
||||||
</Head>
|
</Head>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||||
|
|
||||||
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
|
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
|
||||||
|
|
||||||
@@ -65,6 +66,8 @@ const Mariadb = (
|
|||||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
projectId: data?.environment?.projectId || "",
|
projectId: data?.environment?.projectId || "",
|
||||||
});
|
});
|
||||||
|
const { config: whitelabeling } = useWhitelabeling();
|
||||||
|
const appName = whitelabeling?.appName || "Dokploy";
|
||||||
const environmentDropdownItems =
|
const environmentDropdownItems =
|
||||||
environments?.map((env) => ({
|
environments?.map((env) => ({
|
||||||
name: env.name,
|
name: env.name,
|
||||||
@@ -94,7 +97,7 @@ const Mariadb = (
|
|||||||
<Head>
|
<Head>
|
||||||
<title>
|
<title>
|
||||||
Database: {data?.name} - {data?.environment?.project?.name} |
|
Database: {data?.name} - {data?.environment?.project?.name} |
|
||||||
Dokploy
|
{appName}
|
||||||
</title>
|
</title>
|
||||||
</Head>
|
</Head>
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||||
|
|
||||||
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
|
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
|
||||||
|
|
||||||
@@ -64,6 +65,8 @@ const Mongo = (
|
|||||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
projectId: data?.environment?.projectId || "",
|
projectId: data?.environment?.projectId || "",
|
||||||
});
|
});
|
||||||
|
const { config: whitelabeling } = useWhitelabeling();
|
||||||
|
const appName = whitelabeling?.appName || "Dokploy";
|
||||||
const environmentDropdownItems =
|
const environmentDropdownItems =
|
||||||
environments?.map((env) => ({
|
environments?.map((env) => ({
|
||||||
name: env.name,
|
name: env.name,
|
||||||
@@ -91,7 +94,8 @@ const Mongo = (
|
|||||||
/>
|
/>
|
||||||
<Head>
|
<Head>
|
||||||
<title>
|
<title>
|
||||||
Database: {data?.name} - {data?.environment?.project?.name} | Dokploy
|
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
|
||||||
|
{appName}
|
||||||
</title>
|
</title>
|
||||||
</Head>
|
</Head>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||||
|
|
||||||
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
|
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
|
||||||
|
|
||||||
@@ -63,6 +64,8 @@ const MySql = (
|
|||||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
projectId: data?.environment?.projectId || "",
|
projectId: data?.environment?.projectId || "",
|
||||||
});
|
});
|
||||||
|
const { config: whitelabeling } = useWhitelabeling();
|
||||||
|
const appName = whitelabeling?.appName || "Dokploy";
|
||||||
const environmentDropdownItems =
|
const environmentDropdownItems =
|
||||||
environments?.map((env) => ({
|
environments?.map((env) => ({
|
||||||
name: env.name,
|
name: env.name,
|
||||||
@@ -92,7 +95,7 @@ const MySql = (
|
|||||||
<Head>
|
<Head>
|
||||||
<title>
|
<title>
|
||||||
Database: {data?.name} - {data?.environment?.project?.name} |
|
Database: {data?.name} - {data?.environment?.project?.name} |
|
||||||
Dokploy
|
{appName}
|
||||||
</title>
|
</title>
|
||||||
</Head>
|
</Head>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||||
|
|
||||||
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
|
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
|
||||||
|
|
||||||
@@ -63,6 +64,8 @@ const Postgresql = (
|
|||||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
projectId: data?.environment?.projectId || "",
|
projectId: data?.environment?.projectId || "",
|
||||||
});
|
});
|
||||||
|
const { config: whitelabeling } = useWhitelabeling();
|
||||||
|
const appName = whitelabeling?.appName || "Dokploy";
|
||||||
const environmentDropdownItems =
|
const environmentDropdownItems =
|
||||||
environments?.map((env) => ({
|
environments?.map((env) => ({
|
||||||
name: env.name,
|
name: env.name,
|
||||||
@@ -90,7 +93,8 @@ const Postgresql = (
|
|||||||
/>
|
/>
|
||||||
<Head>
|
<Head>
|
||||||
<title>
|
<title>
|
||||||
Database: {data?.name} - {data?.environment?.project?.name} | Dokploy
|
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
|
||||||
|
{appName}
|
||||||
</title>
|
</title>
|
||||||
</Head>
|
</Head>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||||
|
|
||||||
type TabState = "projects" | "monitoring" | "settings" | "advanced";
|
type TabState = "projects" | "monitoring" | "settings" | "advanced";
|
||||||
|
|
||||||
@@ -63,6 +64,8 @@ const Redis = (
|
|||||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
projectId: data?.environment?.projectId || "",
|
projectId: data?.environment?.projectId || "",
|
||||||
});
|
});
|
||||||
|
const { config: whitelabeling } = useWhitelabeling();
|
||||||
|
const appName = whitelabeling?.appName || "Dokploy";
|
||||||
const environmentDropdownItems =
|
const environmentDropdownItems =
|
||||||
environments?.map((env) => ({
|
environments?.map((env) => ({
|
||||||
name: env.name,
|
name: env.name,
|
||||||
@@ -90,7 +93,8 @@ const Redis = (
|
|||||||
/>
|
/>
|
||||||
<Head>
|
<Head>
|
||||||
<title>
|
<title>
|
||||||
Database: {data?.name} - {data?.environment?.project?.name} | Dokploy
|
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
|
||||||
|
{appName}
|
||||||
</title>
|
</title>
|
||||||
</Head>
|
</Head>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
|||||||
81
apps/dokploy/pages/dashboard/settings/whitelabeling.tsx
Normal file
81
apps/dokploy/pages/dashboard/settings/whitelabeling.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { validateRequest } from "@dokploy/server";
|
||||||
|
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||||
|
import type { GetServerSidePropsContext } from "next";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import superjson from "superjson";
|
||||||
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
|
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
|
||||||
|
import { WhitelabelingSettings } from "@/components/proprietary/whitelabeling/whitelabeling-settings";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { appRouter } from "@/server/api/root";
|
||||||
|
|
||||||
|
const Page = () => {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
||||||
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||||
|
<div className="rounded-xl bg-background shadow-md">
|
||||||
|
<div className="p-6">
|
||||||
|
<EnterpriseFeatureGate
|
||||||
|
lockedProps={{
|
||||||
|
title: "Enterprise Whitelabeling",
|
||||||
|
description:
|
||||||
|
"Whitelabeling allows you to fully customize logos, colors, CSS, error pages, and more. Add a valid license to configure it.",
|
||||||
|
ctaLabel: "Go to License",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WhitelabelingSettings />
|
||||||
|
</EnterpriseFeatureGate>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
||||||
|
|
||||||
|
Page.getLayout = (page: ReactElement) => {
|
||||||
|
return <DashboardLayout metaName="Whitelabeling">{page}</DashboardLayout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||||
|
const { req, res } = ctx;
|
||||||
|
const { user, session } = await validateRequest(ctx.req);
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: true,
|
||||||
|
destination: "/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (user.role !== "owner") {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: true,
|
||||||
|
destination: "/dashboard/settings/profile",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const helpers = createServerSideHelpers({
|
||||||
|
router: appRouter,
|
||||||
|
ctx: {
|
||||||
|
req: req as any,
|
||||||
|
res: res as any,
|
||||||
|
db: null as any,
|
||||||
|
session: session as any,
|
||||||
|
user: user as any,
|
||||||
|
},
|
||||||
|
transformer: superjson,
|
||||||
|
});
|
||||||
|
await helpers.user.get.prefetch();
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
trpcState: helpers.dehydrate(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||||
|
|
||||||
const LoginSchema = z.object({
|
const LoginSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
@@ -58,6 +59,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
export default function Home({ IS_CLOUD }: Props) {
|
export default function Home({ IS_CLOUD }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||||
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
|
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
|
||||||
const [isLoginLoading, setIsLoginLoading] = useState(false);
|
const [isLoginLoading, setIsLoginLoading] = useState(false);
|
||||||
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
|
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
|
||||||
@@ -216,7 +218,14 @@ export default function Home({ IS_CLOUD }: Props) {
|
|||||||
<div className="flex flex-col space-y-2 text-center">
|
<div className="flex flex-col space-y-2 text-center">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
<div className="flex flex-row items-center justify-center gap-2">
|
<div className="flex flex-row items-center justify-center gap-2">
|
||||||
<Logo className="size-12" />
|
<Logo
|
||||||
|
className="size-12"
|
||||||
|
logoUrl={
|
||||||
|
whitelabeling?.loginLogoUrl ||
|
||||||
|
whitelabeling?.logoUrl ||
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
Sign in
|
Sign in
|
||||||
</div>
|
</div>
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||||
|
|
||||||
const registerSchema = z
|
const registerSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -82,6 +83,7 @@ const Invitation = ({
|
|||||||
userAlreadyExists,
|
userAlreadyExists,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||||
const { data } = api.user.getUserByToken.useQuery(
|
const { data } = api.user.getUserByToken.useQuery(
|
||||||
{
|
{
|
||||||
token,
|
token,
|
||||||
@@ -148,12 +150,15 @@ const Invitation = ({
|
|||||||
<div className="flex h-screen w-full items-center justify-center ">
|
<div className="flex h-screen w-full items-center justify-center ">
|
||||||
<div className="flex flex-col items-center gap-4 w-full">
|
<div className="flex flex-col items-center gap-4 w-full">
|
||||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||||
<Link
|
<Link href="/" className="flex flex-row items-center gap-2">
|
||||||
href="https://dokploy.com"
|
<Logo
|
||||||
target="_blank"
|
className="size-12"
|
||||||
className="flex flex-row items-center gap-2"
|
logoUrl={
|
||||||
>
|
whitelabeling?.loginLogoUrl ||
|
||||||
<Logo className="size-12" />
|
whitelabeling?.logoUrl ||
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
Invitation
|
Invitation
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} 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 { authClient } from "@/lib/auth-client";
|
||||||
|
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||||
|
|
||||||
const registerSchema = z
|
const registerSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -77,6 +78,7 @@ interface Props {
|
|||||||
|
|
||||||
const Register = ({ isCloud }: Props) => {
|
const Register = ({ isCloud }: Props) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||||
const [isError, setIsError] = useState(false);
|
const [isError, setIsError] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [data, setData] = useState<any>(null);
|
const [data, setData] = useState<any>(null);
|
||||||
@@ -123,12 +125,15 @@ const Register = ({ isCloud }: Props) => {
|
|||||||
<div className="flex w-full items-center justify-center ">
|
<div className="flex w-full items-center justify-center ">
|
||||||
<div className="flex flex-col items-center gap-4 w-full">
|
<div className="flex flex-col items-center gap-4 w-full">
|
||||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||||
<Link
|
<Link href="/" className="flex flex-row items-center gap-2">
|
||||||
href="https://dokploy.com"
|
<Logo
|
||||||
target="_blank"
|
className="size-12"
|
||||||
className="flex flex-row items-center gap-2"
|
logoUrl={
|
||||||
>
|
whitelabeling?.loginLogoUrl ||
|
||||||
<Logo className="size-12" />
|
whitelabeling?.logoUrl ||
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
{isCloud ? "Sign Up" : "Setup the server"}
|
{isCloud ? "Sign Up" : "Setup the server"}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} 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 { authClient } from "@/lib/auth-client";
|
||||||
|
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||||
|
|
||||||
const loginSchema = z
|
const loginSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -53,6 +54,7 @@ interface Props {
|
|||||||
tokenResetPassword: string;
|
tokenResetPassword: string;
|
||||||
}
|
}
|
||||||
export default function Home({ tokenResetPassword }: Props) {
|
export default function Home({ tokenResetPassword }: Props) {
|
||||||
|
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||||
const [token, setToken] = useState<string | null>(tokenResetPassword);
|
const [token, setToken] = useState<string | null>(tokenResetPassword);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -97,7 +99,14 @@ export default function Home({ tokenResetPassword }: Props) {
|
|||||||
<div className="flex flex-col items-center gap-4 w-full">
|
<div className="flex flex-col items-center gap-4 w-full">
|
||||||
<CardTitle className="text-2xl font-bold flex flex-row gap-2 items-center">
|
<CardTitle className="text-2xl font-bold flex flex-row gap-2 items-center">
|
||||||
<Link href="/" className="flex flex-row items-center gap-2">
|
<Link href="/" className="flex flex-row items-center gap-2">
|
||||||
<Logo className="size-12" />
|
<Logo
|
||||||
|
className="size-12"
|
||||||
|
logoUrl={
|
||||||
|
whitelabeling?.loginLogoUrl ||
|
||||||
|
whitelabeling?.logoUrl ||
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
Reset Password
|
Reset Password
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} 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 { authClient } from "@/lib/auth-client";
|
||||||
|
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
email: z
|
email: z
|
||||||
@@ -42,6 +43,7 @@ type AuthResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||||
const [temp, _setTemp] = useState<AuthResponse>({
|
const [temp, _setTemp] = useState<AuthResponse>({
|
||||||
is2FAEnabled: false,
|
is2FAEnabled: false,
|
||||||
authId: "",
|
authId: "",
|
||||||
@@ -81,8 +83,14 @@ export default function Home() {
|
|||||||
<div className="flex w-full items-center justify-center ">
|
<div className="flex w-full items-center justify-center ">
|
||||||
<div className="flex flex-col items-center gap-4 w-full">
|
<div className="flex flex-col items-center gap-4 w-full">
|
||||||
<Link href="/" className="flex flex-row items-center gap-2">
|
<Link href="/" className="flex flex-row items-center gap-2">
|
||||||
<Logo />
|
<Logo
|
||||||
<span className="font-medium text-sm">Dokploy</span>
|
logoUrl={
|
||||||
|
whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
{whitelabeling?.appName || "Dokploy"}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<CardTitle className="text-2xl font-bold">Reset Password</CardTitle>
|
<CardTitle className="text-2xl font-bold">Reset Password</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
# Proprietary Features
|
|
||||||
|
|
||||||
This directory contains all proprietary functionality of Dokploy.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This folder will house all **paid features** and premium functionality that are not part of the open source code.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
The code in this directory is under Dokploy's proprietary license. See [LICENSE_PROPRIETARY.md](../../../LICENSE_PROPRIETARY.md) for more details.
|
|
||||||
|
|
||||||
## Contact
|
|
||||||
|
|
||||||
If you want to learn more about our paid features or have any questions, please contact us at:
|
|
||||||
|
|
||||||
- Email: [sales@dokploy.com](mailto:sales@dokploy.com)
|
|
||||||
- Contact Form: [https://dokploy.com/contact](https://dokploy.com/contact)
|
|
||||||
@@ -25,6 +25,7 @@ import { organizationRouter } from "./routers/organization";
|
|||||||
import { patchRouter } from "./routers/patch";
|
import { patchRouter } from "./routers/patch";
|
||||||
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||||
import { ssoRouter } from "./routers/proprietary/sso";
|
import { ssoRouter } from "./routers/proprietary/sso";
|
||||||
|
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
|
||||||
import { portRouter } from "./routers/port";
|
import { portRouter } from "./routers/port";
|
||||||
import { postgresRouter } from "./routers/postgres";
|
import { postgresRouter } from "./routers/postgres";
|
||||||
import { previewDeploymentRouter } from "./routers/preview-deployment";
|
import { previewDeploymentRouter } from "./routers/preview-deployment";
|
||||||
@@ -87,6 +88,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
organization: organizationRouter,
|
organization: organizationRouter,
|
||||||
licenseKey: licenseKeyRouter,
|
licenseKey: licenseKeyRouter,
|
||||||
sso: ssoRouter,
|
sso: ssoRouter,
|
||||||
|
whitelabeling: whitelabelingRouter,
|
||||||
schedule: scheduleRouter,
|
schedule: scheduleRouter,
|
||||||
rollback: rollbackRouter,
|
rollback: rollbackRouter,
|
||||||
volumeBackups: volumeBackupsRouter,
|
volumeBackups: volumeBackupsRouter,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
findApplicationById,
|
findApplicationById,
|
||||||
findEnvironmentById,
|
findEnvironmentById,
|
||||||
findGitProviderById,
|
findGitProviderById,
|
||||||
|
findMemberById,
|
||||||
findProjectById,
|
findProjectById,
|
||||||
getApplicationStats,
|
getApplicationStats,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
@@ -32,7 +33,7 @@ import {
|
|||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||||
@@ -53,6 +54,8 @@ import {
|
|||||||
apiSaveGitProvider,
|
apiSaveGitProvider,
|
||||||
apiUpdateApplication,
|
apiUpdateApplication,
|
||||||
applications,
|
applications,
|
||||||
|
environments,
|
||||||
|
projects,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
import { deploymentWorker } from "@/server/queues/deployments-queue";
|
import { deploymentWorker } from "@/server/queues/deployments-queue";
|
||||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||||
@@ -1002,4 +1005,138 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
message: "Deployment cancellation only available in cloud version",
|
message: "Deployment cancellation only available in cloud version",
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
search: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
q: z.string().optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
appName: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
repository: z.string().optional(),
|
||||||
|
owner: z.string().optional(),
|
||||||
|
dockerImage: z.string().optional(),
|
||||||
|
projectId: z.string().optional(),
|
||||||
|
environmentId: z.string().optional(),
|
||||||
|
limit: z.number().min(1).max(100).default(20),
|
||||||
|
offset: z.number().min(0).default(0),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const baseConditions = [
|
||||||
|
eq(projects.organizationId, ctx.session.activeOrganizationId),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (input.projectId) {
|
||||||
|
baseConditions.push(eq(environments.projectId, input.projectId));
|
||||||
|
}
|
||||||
|
if (input.environmentId) {
|
||||||
|
baseConditions.push(
|
||||||
|
eq(applications.environmentId, input.environmentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.q?.trim()) {
|
||||||
|
const term = `%${input.q.trim()}%`;
|
||||||
|
baseConditions.push(
|
||||||
|
or(
|
||||||
|
ilike(applications.name, term),
|
||||||
|
ilike(applications.appName, term),
|
||||||
|
ilike(applications.description ?? "", term),
|
||||||
|
ilike(applications.repository ?? "", term),
|
||||||
|
ilike(applications.owner ?? "", term),
|
||||||
|
ilike(applications.dockerImage ?? "", term),
|
||||||
|
)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.name?.trim()) {
|
||||||
|
baseConditions.push(ilike(applications.name, `%${input.name.trim()}%`));
|
||||||
|
}
|
||||||
|
if (input.appName?.trim()) {
|
||||||
|
baseConditions.push(
|
||||||
|
ilike(applications.appName, `%${input.appName.trim()}%`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.description?.trim()) {
|
||||||
|
baseConditions.push(
|
||||||
|
ilike(
|
||||||
|
applications.description ?? "",
|
||||||
|
`%${input.description.trim()}%`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.repository?.trim()) {
|
||||||
|
baseConditions.push(
|
||||||
|
ilike(applications.repository ?? "", `%${input.repository.trim()}%`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.owner?.trim()) {
|
||||||
|
baseConditions.push(
|
||||||
|
ilike(applications.owner ?? "", `%${input.owner.trim()}%`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.dockerImage?.trim()) {
|
||||||
|
baseConditions.push(
|
||||||
|
ilike(
|
||||||
|
applications.dockerImage ?? "",
|
||||||
|
`%${input.dockerImage.trim()}%`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.user.role === "member") {
|
||||||
|
const { accessedServices } = await findMemberById(
|
||||||
|
ctx.user.id,
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
);
|
||||||
|
if (accessedServices.length === 0) return { items: [], total: 0 };
|
||||||
|
baseConditions.push(
|
||||||
|
sql`${applications.applicationId} IN (${sql.join(
|
||||||
|
accessedServices.map((id) => sql`${id}`),
|
||||||
|
sql`, `,
|
||||||
|
)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = and(...baseConditions);
|
||||||
|
|
||||||
|
const [items, countResult] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
applicationId: applications.applicationId,
|
||||||
|
name: applications.name,
|
||||||
|
appName: applications.appName,
|
||||||
|
description: applications.description,
|
||||||
|
environmentId: applications.environmentId,
|
||||||
|
applicationStatus: applications.applicationStatus,
|
||||||
|
sourceType: applications.sourceType,
|
||||||
|
createdAt: applications.createdAt,
|
||||||
|
})
|
||||||
|
.from(applications)
|
||||||
|
.innerJoin(
|
||||||
|
environments,
|
||||||
|
eq(applications.environmentId, environments.environmentId),
|
||||||
|
)
|
||||||
|
.innerJoin(projects, eq(environments.projectId, projects.projectId))
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(applications.createdAt))
|
||||||
|
.limit(input.limit)
|
||||||
|
.offset(input.offset),
|
||||||
|
db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(applications)
|
||||||
|
.innerJoin(
|
||||||
|
environments,
|
||||||
|
eq(applications.environmentId, environments.environmentId),
|
||||||
|
)
|
||||||
|
.innerJoin(projects, eq(environments.projectId, projects.projectId))
|
||||||
|
.where(where),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total: countResult[0]?.count ?? 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
findDomainsByComposeId,
|
findDomainsByComposeId,
|
||||||
findEnvironmentById,
|
findEnvironmentById,
|
||||||
findGitProviderById,
|
findGitProviderById,
|
||||||
|
findMemberById,
|
||||||
findProjectById,
|
findProjectById,
|
||||||
findServerById,
|
findServerById,
|
||||||
getComposeContainer,
|
getComposeContainer,
|
||||||
@@ -41,7 +42,7 @@ import {
|
|||||||
} from "@dokploy/server/templates/github";
|
} from "@dokploy/server/templates/github";
|
||||||
import { processTemplate } from "@dokploy/server/templates/processors";
|
import { processTemplate } from "@dokploy/server/templates/processors";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { parse } from "toml";
|
import { parse } from "toml";
|
||||||
@@ -58,6 +59,8 @@ import {
|
|||||||
apiRedeployCompose,
|
apiRedeployCompose,
|
||||||
apiUpdateCompose,
|
apiUpdateCompose,
|
||||||
compose as composeTable,
|
compose as composeTable,
|
||||||
|
environments,
|
||||||
|
projects,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
import { deploymentWorker } from "@/server/queues/deployments-queue";
|
import { deploymentWorker } from "@/server/queues/deployments-queue";
|
||||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||||
@@ -1054,4 +1057,114 @@ export const composeRouter = createTRPCRouter({
|
|||||||
message: "Deployment cancellation only available in cloud version",
|
message: "Deployment cancellation only available in cloud version",
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
search: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
q: z.string().optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
appName: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
projectId: z.string().optional(),
|
||||||
|
environmentId: z.string().optional(),
|
||||||
|
limit: z.number().min(1).max(100).default(20),
|
||||||
|
offset: z.number().min(0).default(0),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const baseConditions = [
|
||||||
|
eq(projects.organizationId, ctx.session.activeOrganizationId),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (input.projectId) {
|
||||||
|
baseConditions.push(eq(environments.projectId, input.projectId));
|
||||||
|
}
|
||||||
|
if (input.environmentId) {
|
||||||
|
baseConditions.push(
|
||||||
|
eq(composeTable.environmentId, input.environmentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.q?.trim()) {
|
||||||
|
const term = `%${input.q.trim()}%`;
|
||||||
|
baseConditions.push(
|
||||||
|
or(
|
||||||
|
ilike(composeTable.name, term),
|
||||||
|
ilike(composeTable.appName, term),
|
||||||
|
ilike(composeTable.description ?? "", term),
|
||||||
|
)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.name?.trim()) {
|
||||||
|
baseConditions.push(ilike(composeTable.name, `%${input.name.trim()}%`));
|
||||||
|
}
|
||||||
|
if (input.appName?.trim()) {
|
||||||
|
baseConditions.push(
|
||||||
|
ilike(composeTable.appName, `%${input.appName.trim()}%`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.description?.trim()) {
|
||||||
|
baseConditions.push(
|
||||||
|
ilike(
|
||||||
|
composeTable.description ?? "",
|
||||||
|
`%${input.description.trim()}%`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.user.role === "member") {
|
||||||
|
const { accessedServices } = await findMemberById(
|
||||||
|
ctx.user.id,
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
);
|
||||||
|
if (accessedServices.length === 0) return { items: [], total: 0 };
|
||||||
|
baseConditions.push(
|
||||||
|
sql`${composeTable.composeId} IN (${sql.join(
|
||||||
|
accessedServices.map((id) => sql`${id}`),
|
||||||
|
sql`, `,
|
||||||
|
)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = and(...baseConditions);
|
||||||
|
|
||||||
|
const [items, countResult] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
composeId: composeTable.composeId,
|
||||||
|
name: composeTable.name,
|
||||||
|
appName: composeTable.appName,
|
||||||
|
description: composeTable.description,
|
||||||
|
environmentId: composeTable.environmentId,
|
||||||
|
composeStatus: composeTable.composeStatus,
|
||||||
|
sourceType: composeTable.sourceType,
|
||||||
|
createdAt: composeTable.createdAt,
|
||||||
|
})
|
||||||
|
.from(composeTable)
|
||||||
|
.innerJoin(
|
||||||
|
environments,
|
||||||
|
eq(composeTable.environmentId, environments.environmentId),
|
||||||
|
)
|
||||||
|
.innerJoin(projects, eq(environments.projectId, projects.projectId))
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(composeTable.createdAt))
|
||||||
|
.limit(input.limit)
|
||||||
|
.offset(input.offset),
|
||||||
|
db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(composeTable)
|
||||||
|
.innerJoin(
|
||||||
|
environments,
|
||||||
|
eq(composeTable.environmentId, environments.environmentId),
|
||||||
|
)
|
||||||
|
.innerJoin(projects, eq(environments.projectId, projects.projectId))
|
||||||
|
.where(where),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total: countResult[0]?.count ?? 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ import {
|
|||||||
findAllDeploymentsByApplicationId,
|
findAllDeploymentsByApplicationId,
|
||||||
findAllDeploymentsByComposeId,
|
findAllDeploymentsByComposeId,
|
||||||
findAllDeploymentsByServerId,
|
findAllDeploymentsByServerId,
|
||||||
|
findAllDeploymentsCentralized,
|
||||||
findApplicationById,
|
findApplicationById,
|
||||||
findComposeById,
|
findComposeById,
|
||||||
findDeploymentById,
|
findDeploymentById,
|
||||||
|
findMemberById,
|
||||||
findServerById,
|
findServerById,
|
||||||
|
IS_CLOUD,
|
||||||
removeDeployment,
|
removeDeployment,
|
||||||
|
resolveServicePath,
|
||||||
updateDeploymentStatus,
|
updateDeploymentStatus,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
@@ -21,7 +25,10 @@ import {
|
|||||||
apiFindAllByServer,
|
apiFindAllByServer,
|
||||||
apiFindAllByType,
|
apiFindAllByType,
|
||||||
deployments,
|
deployments,
|
||||||
|
server,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
|
import { myQueue } from "@/server/queues/queueSetup";
|
||||||
|
import { fetchDeployApiJobs, type QueueJobRow } from "@/server/utils/deploy";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
|
||||||
export const deploymentRouter = createTRPCRouter({
|
export const deploymentRouter = createTRPCRouter({
|
||||||
@@ -68,6 +75,63 @@ export const deploymentRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
return await findAllDeploymentsByServerId(input.serverId);
|
return await findAllDeploymentsByServerId(input.serverId);
|
||||||
}),
|
}),
|
||||||
|
allCentralized: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const orgId = ctx.session.activeOrganizationId;
|
||||||
|
const accessedServices =
|
||||||
|
ctx.user.role === "member"
|
||||||
|
? (await findMemberById(ctx.user.id, orgId)).accessedServices
|
||||||
|
: null;
|
||||||
|
if (accessedServices !== null && accessedServices.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return findAllDeploymentsCentralized(orgId, accessedServices);
|
||||||
|
}),
|
||||||
|
|
||||||
|
queueList: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const orgId = ctx.session.activeOrganizationId;
|
||||||
|
let rows: QueueJobRow[];
|
||||||
|
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
const servers = await db.query.server.findMany({
|
||||||
|
where: eq(server.organizationId, orgId),
|
||||||
|
columns: { serverId: true },
|
||||||
|
});
|
||||||
|
const serverRowsArrays = await Promise.all(
|
||||||
|
servers.map(({ serverId }) => fetchDeployApiJobs(serverId)),
|
||||||
|
);
|
||||||
|
rows = serverRowsArrays.flat();
|
||||||
|
rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
|
||||||
|
} else {
|
||||||
|
const jobs = await myQueue.getJobs();
|
||||||
|
const jobRows = await Promise.all(
|
||||||
|
jobs.map(async (job) => {
|
||||||
|
const state = await job.getState();
|
||||||
|
return {
|
||||||
|
id: String(job.id),
|
||||||
|
name: job.name ?? undefined,
|
||||||
|
data: job.data as Record<string, unknown>,
|
||||||
|
timestamp: job.timestamp,
|
||||||
|
processedOn: job.processedOn,
|
||||||
|
finishedOn: job.finishedOn,
|
||||||
|
failedReason: job.failedReason ?? undefined,
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
jobRows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
|
||||||
|
rows = jobRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
rows.map(async (row) => ({
|
||||||
|
...row,
|
||||||
|
servicePath: await resolveServicePath(
|
||||||
|
orgId,
|
||||||
|
(row.data ?? {}) as Record<string, unknown>,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
allByType: protectedProcedure
|
allByType: protectedProcedure
|
||||||
.input(apiFindAllByType)
|
.input(apiFindAllByType)
|
||||||
@@ -79,10 +143,8 @@ export const deploymentRouter = createTRPCRouter({
|
|||||||
rollback: true,
|
rollback: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return deploymentsList;
|
return deploymentsList;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
killProcess: protectedProcedure
|
killProcess: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import {
|
|||||||
findMemberById,
|
findMemberById,
|
||||||
updateEnvironmentById,
|
updateEnvironmentById,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
|
import { db } from "@dokploy/server/db";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +23,7 @@ import {
|
|||||||
apiRemoveEnvironment,
|
apiRemoveEnvironment,
|
||||||
apiUpdateEnvironment,
|
apiUpdateEnvironment,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
|
import { environments, projects } from "@/server/db/schema";
|
||||||
|
|
||||||
// Helper function to filter services within an environment based on user permissions
|
// Helper function to filter services within an environment based on user permissions
|
||||||
const filterEnvironmentServices = (
|
const filterEnvironmentServices = (
|
||||||
@@ -358,4 +361,92 @@ export const environmentRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
search: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
q: z.string().optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
projectId: z.string().optional(),
|
||||||
|
limit: z.number().min(1).max(100).default(20),
|
||||||
|
offset: z.number().min(0).default(0),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const baseConditions = [
|
||||||
|
eq(projects.organizationId, ctx.session.activeOrganizationId),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (input.projectId) {
|
||||||
|
baseConditions.push(eq(environments.projectId, input.projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.q?.trim()) {
|
||||||
|
const term = `%${input.q.trim()}%`;
|
||||||
|
baseConditions.push(
|
||||||
|
or(
|
||||||
|
ilike(environments.name, term),
|
||||||
|
ilike(environments.description ?? "", term),
|
||||||
|
)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.name?.trim()) {
|
||||||
|
baseConditions.push(ilike(environments.name, `%${input.name.trim()}%`));
|
||||||
|
}
|
||||||
|
if (input.description?.trim()) {
|
||||||
|
baseConditions.push(
|
||||||
|
ilike(
|
||||||
|
environments.description ?? "",
|
||||||
|
`%${input.description.trim()}%`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.user.role === "member") {
|
||||||
|
const { accessedEnvironments } = await findMemberById(
|
||||||
|
ctx.user.id,
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
);
|
||||||
|
if (accessedEnvironments.length === 0) return { items: [], total: 0 };
|
||||||
|
baseConditions.push(
|
||||||
|
sql`${environments.environmentId} IN (${sql.join(
|
||||||
|
accessedEnvironments.map((id) => sql`${id}`),
|
||||||
|
sql`, `,
|
||||||
|
)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = and(...baseConditions);
|
||||||
|
|
||||||
|
const [items, countResult] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
environmentId: environments.environmentId,
|
||||||
|
name: environments.name,
|
||||||
|
description: environments.description,
|
||||||
|
createdAt: environments.createdAt,
|
||||||
|
env: environments.env,
|
||||||
|
projectId: environments.projectId,
|
||||||
|
isDefault: environments.isDefault,
|
||||||
|
})
|
||||||
|
.from(environments)
|
||||||
|
.innerJoin(projects, eq(environments.projectId, projects.projectId))
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(environments.createdAt))
|
||||||
|
.limit(input.limit)
|
||||||
|
.offset(input.offset),
|
||||||
|
db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(environments)
|
||||||
|
.innerJoin(projects, eq(environments.projectId, projects.projectId))
|
||||||
|
.where(where),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total: countResult[0]?.count ?? 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
findBackupsByDbId,
|
findBackupsByDbId,
|
||||||
findEnvironmentById,
|
findEnvironmentById,
|
||||||
findMariadbById,
|
findMariadbById,
|
||||||
|
findMemberById,
|
||||||
findProjectById,
|
findProjectById,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
rebuildDatabase,
|
rebuildDatabase,
|
||||||
@@ -22,7 +23,7 @@ import {
|
|||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { observable } from "@trpc/server/observable";
|
import { observable } from "@trpc/server/observable";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +38,7 @@ import {
|
|||||||
apiUpdateMariaDB,
|
apiUpdateMariaDB,
|
||||||
mariadb as mariadbTable,
|
mariadb as mariadbTable,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
|
import { environments, projects } from "@/server/db/schema";
|
||||||
import { cancelJobs } from "@/server/utils/backup";
|
import { cancelJobs } from "@/server/utils/backup";
|
||||||
export const mariadbRouter = createTRPCRouter({
|
export const mariadbRouter = createTRPCRouter({
|
||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
@@ -446,4 +448,102 @@ export const mariadbRouter = createTRPCRouter({
|
|||||||
await rebuildDatabase(mariadb.mariadbId, "mariadb");
|
await rebuildDatabase(mariadb.mariadbId, "mariadb");
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
|
search: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
q: z.string().optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
appName: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
projectId: z.string().optional(),
|
||||||
|
environmentId: z.string().optional(),
|
||||||
|
limit: z.number().min(1).max(100).default(20),
|
||||||
|
offset: z.number().min(0).default(0),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const baseConditions = [
|
||||||
|
eq(projects.organizationId, ctx.session.activeOrganizationId),
|
||||||
|
];
|
||||||
|
if (input.projectId) {
|
||||||
|
baseConditions.push(eq(environments.projectId, input.projectId));
|
||||||
|
}
|
||||||
|
if (input.environmentId) {
|
||||||
|
baseConditions.push(
|
||||||
|
eq(mariadbTable.environmentId, input.environmentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.q?.trim()) {
|
||||||
|
const term = `%${input.q.trim()}%`;
|
||||||
|
baseConditions.push(
|
||||||
|
or(
|
||||||
|
ilike(mariadbTable.name, term),
|
||||||
|
ilike(mariadbTable.appName, term),
|
||||||
|
ilike(mariadbTable.description ?? "", term),
|
||||||
|
)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.name?.trim()) {
|
||||||
|
baseConditions.push(ilike(mariadbTable.name, `%${input.name.trim()}%`));
|
||||||
|
}
|
||||||
|
if (input.appName?.trim()) {
|
||||||
|
baseConditions.push(
|
||||||
|
ilike(mariadbTable.appName, `%${input.appName.trim()}%`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.description?.trim()) {
|
||||||
|
baseConditions.push(
|
||||||
|
ilike(
|
||||||
|
mariadbTable.description ?? "",
|
||||||
|
`%${input.description.trim()}%`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ctx.user.role === "member") {
|
||||||
|
const { accessedServices } = await findMemberById(
|
||||||
|
ctx.user.id,
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
);
|
||||||
|
if (accessedServices.length === 0) return { items: [], total: 0 };
|
||||||
|
baseConditions.push(
|
||||||
|
sql`${mariadbTable.mariadbId} IN (${sql.join(
|
||||||
|
accessedServices.map((id) => sql`${id}`),
|
||||||
|
sql`, `,
|
||||||
|
)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const where = and(...baseConditions);
|
||||||
|
const [items, countResult] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
mariadbId: mariadbTable.mariadbId,
|
||||||
|
name: mariadbTable.name,
|
||||||
|
appName: mariadbTable.appName,
|
||||||
|
description: mariadbTable.description,
|
||||||
|
environmentId: mariadbTable.environmentId,
|
||||||
|
applicationStatus: mariadbTable.applicationStatus,
|
||||||
|
createdAt: mariadbTable.createdAt,
|
||||||
|
})
|
||||||
|
.from(mariadbTable)
|
||||||
|
.innerJoin(
|
||||||
|
environments,
|
||||||
|
eq(mariadbTable.environmentId, environments.environmentId),
|
||||||
|
)
|
||||||
|
.innerJoin(projects, eq(environments.projectId, projects.projectId))
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(mariadbTable.createdAt))
|
||||||
|
.limit(input.limit)
|
||||||
|
.offset(input.offset),
|
||||||
|
db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(mariadbTable)
|
||||||
|
.innerJoin(
|
||||||
|
environments,
|
||||||
|
eq(mariadbTable.environmentId, environments.environmentId),
|
||||||
|
)
|
||||||
|
.innerJoin(projects, eq(environments.projectId, projects.projectId))
|
||||||
|
.where(where),
|
||||||
|
]);
|
||||||
|
return { items, total: countResult[0]?.count ?? 0 };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
deployMongo,
|
deployMongo,
|
||||||
findBackupsByDbId,
|
findBackupsByDbId,
|
||||||
findEnvironmentById,
|
findEnvironmentById,
|
||||||
|
findMemberById,
|
||||||
findMongoById,
|
findMongoById,
|
||||||
findProjectById,
|
findProjectById,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
@@ -21,7 +22,7 @@ import {
|
|||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
apiUpdateMongo,
|
apiUpdateMongo,
|
||||||
mongo as mongoTable,
|
mongo as mongoTable,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
|
import { environments, projects } from "@/server/db/schema";
|
||||||
import { cancelJobs } from "@/server/utils/backup";
|
import { cancelJobs } from "@/server/utils/backup";
|
||||||
export const mongoRouter = createTRPCRouter({
|
export const mongoRouter = createTRPCRouter({
|
||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
@@ -476,4 +478,97 @@ export const mongoRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
|
search: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
q: z.string().optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
appName: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
projectId: z.string().optional(),
|
||||||
|
environmentId: z.string().optional(),
|
||||||
|
limit: z.number().min(1).max(100).default(20),
|
||||||
|
offset: z.number().min(0).default(0),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const baseConditions = [
|
||||||
|
eq(projects.organizationId, ctx.session.activeOrganizationId),
|
||||||
|
];
|
||||||
|
if (input.projectId) {
|
||||||
|
baseConditions.push(eq(environments.projectId, input.projectId));
|
||||||
|
}
|
||||||
|
if (input.environmentId) {
|
||||||
|
baseConditions.push(eq(mongoTable.environmentId, input.environmentId));
|
||||||
|
}
|
||||||
|
if (input.q?.trim()) {
|
||||||
|
const term = `%${input.q.trim()}%`;
|
||||||
|
baseConditions.push(
|
||||||
|
or(
|
||||||
|
ilike(mongoTable.name, term),
|
||||||
|
ilike(mongoTable.appName, term),
|
||||||
|
ilike(mongoTable.description ?? "", term),
|
||||||
|
)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.name?.trim()) {
|
||||||
|
baseConditions.push(ilike(mongoTable.name, `%${input.name.trim()}%`));
|
||||||
|
}
|
||||||
|
if (input.appName?.trim()) {
|
||||||
|
baseConditions.push(
|
||||||
|
ilike(mongoTable.appName, `%${input.appName.trim()}%`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.description?.trim()) {
|
||||||
|
baseConditions.push(
|
||||||
|
ilike(mongoTable.description ?? "", `%${input.description.trim()}%`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ctx.user.role === "member") {
|
||||||
|
const { accessedServices } = await findMemberById(
|
||||||
|
ctx.user.id,
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
);
|
||||||
|
if (accessedServices.length === 0) return { items: [], total: 0 };
|
||||||
|
baseConditions.push(
|
||||||
|
sql`${mongoTable.mongoId} IN (${sql.join(
|
||||||
|
accessedServices.map((id) => sql`${id}`),
|
||||||
|
sql`, `,
|
||||||
|
)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const where = and(...baseConditions);
|
||||||
|
const [items, countResult] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
mongoId: mongoTable.mongoId,
|
||||||
|
name: mongoTable.name,
|
||||||
|
appName: mongoTable.appName,
|
||||||
|
description: mongoTable.description,
|
||||||
|
environmentId: mongoTable.environmentId,
|
||||||
|
applicationStatus: mongoTable.applicationStatus,
|
||||||
|
createdAt: mongoTable.createdAt,
|
||||||
|
})
|
||||||
|
.from(mongoTable)
|
||||||
|
.innerJoin(
|
||||||
|
environments,
|
||||||
|
eq(mongoTable.environmentId, environments.environmentId),
|
||||||
|
)
|
||||||
|
.innerJoin(projects, eq(environments.projectId, projects.projectId))
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(mongoTable.createdAt))
|
||||||
|
.limit(input.limit)
|
||||||
|
.offset(input.offset),
|
||||||
|
db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(mongoTable)
|
||||||
|
.innerJoin(
|
||||||
|
environments,
|
||||||
|
eq(mongoTable.environmentId, environments.environmentId),
|
||||||
|
)
|
||||||
|
.innerJoin(projects, eq(environments.projectId, projects.projectId))
|
||||||
|
.where(where),
|
||||||
|
]);
|
||||||
|
return { items, total: countResult[0]?.count ?? 0 };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,8 +69,7 @@ export const mountRouter = createTRPCRouter({
|
|||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
.input(apiCreateMount)
|
.input(apiCreateMount)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await createMount(input);
|
return await createMount(input);
|
||||||
return true;
|
|
||||||
}),
|
}),
|
||||||
remove: protectedProcedure
|
remove: protectedProcedure
|
||||||
.input(apiRemoveMount)
|
.input(apiRemoveMount)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
deployMySql,
|
deployMySql,
|
||||||
findBackupsByDbId,
|
findBackupsByDbId,
|
||||||
findEnvironmentById,
|
findEnvironmentById,
|
||||||
|
findMemberById,
|
||||||
findMySqlById,
|
findMySqlById,
|
||||||
findProjectById,
|
findProjectById,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
@@ -21,7 +22,7 @@ import {
|
|||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||||
import {
|
import {
|
||||||
@@ -34,7 +35,9 @@ import {
|
|||||||
apiSaveEnvironmentVariablesMySql,
|
apiSaveEnvironmentVariablesMySql,
|
||||||
apiSaveExternalPortMySql,
|
apiSaveExternalPortMySql,
|
||||||
apiUpdateMySql,
|
apiUpdateMySql,
|
||||||
|
environments,
|
||||||
mysql as mysqlTable,
|
mysql as mysqlTable,
|
||||||
|
projects,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
import { cancelJobs } from "@/server/utils/backup";
|
import { cancelJobs } from "@/server/utils/backup";
|
||||||
|
|
||||||
@@ -471,4 +474,97 @@ export const mysqlRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
|
search: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
q: z.string().optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
appName: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
projectId: z.string().optional(),
|
||||||
|
environmentId: z.string().optional(),
|
||||||
|
limit: z.number().min(1).max(100).default(20),
|
||||||
|
offset: z.number().min(0).default(0),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const baseConditions = [
|
||||||
|
eq(projects.organizationId, ctx.session.activeOrganizationId),
|
||||||
|
];
|
||||||
|
if (input.projectId) {
|
||||||
|
baseConditions.push(eq(environments.projectId, input.projectId));
|
||||||
|
}
|
||||||
|
if (input.environmentId) {
|
||||||
|
baseConditions.push(eq(mysqlTable.environmentId, input.environmentId));
|
||||||
|
}
|
||||||
|
if (input.q?.trim()) {
|
||||||
|
const term = `%${input.q.trim()}%`;
|
||||||
|
baseConditions.push(
|
||||||
|
or(
|
||||||
|
ilike(mysqlTable.name, term),
|
||||||
|
ilike(mysqlTable.appName, term),
|
||||||
|
ilike(mysqlTable.description ?? "", term),
|
||||||
|
)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.name?.trim()) {
|
||||||
|
baseConditions.push(ilike(mysqlTable.name, `%${input.name.trim()}%`));
|
||||||
|
}
|
||||||
|
if (input.appName?.trim()) {
|
||||||
|
baseConditions.push(
|
||||||
|
ilike(mysqlTable.appName, `%${input.appName.trim()}%`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.description?.trim()) {
|
||||||
|
baseConditions.push(
|
||||||
|
ilike(mysqlTable.description ?? "", `%${input.description.trim()}%`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ctx.user.role === "member") {
|
||||||
|
const { accessedServices } = await findMemberById(
|
||||||
|
ctx.user.id,
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
);
|
||||||
|
if (accessedServices.length === 0) return { items: [], total: 0 };
|
||||||
|
baseConditions.push(
|
||||||
|
sql`${mysqlTable.mysqlId} IN (${sql.join(
|
||||||
|
accessedServices.map((id) => sql`${id}`),
|
||||||
|
sql`, `,
|
||||||
|
)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const where = and(...baseConditions);
|
||||||
|
const [items, countResult] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
mysqlId: mysqlTable.mysqlId,
|
||||||
|
name: mysqlTable.name,
|
||||||
|
appName: mysqlTable.appName,
|
||||||
|
description: mysqlTable.description,
|
||||||
|
environmentId: mysqlTable.environmentId,
|
||||||
|
applicationStatus: mysqlTable.applicationStatus,
|
||||||
|
createdAt: mysqlTable.createdAt,
|
||||||
|
})
|
||||||
|
.from(mysqlTable)
|
||||||
|
.innerJoin(
|
||||||
|
environments,
|
||||||
|
eq(mysqlTable.environmentId, environments.environmentId),
|
||||||
|
)
|
||||||
|
.innerJoin(projects, eq(environments.projectId, projects.projectId))
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(mysqlTable.createdAt))
|
||||||
|
.limit(input.limit)
|
||||||
|
.offset(input.offset),
|
||||||
|
db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(mysqlTable)
|
||||||
|
.innerJoin(
|
||||||
|
environments,
|
||||||
|
eq(mysqlTable.environmentId, environments.environmentId),
|
||||||
|
)
|
||||||
|
.innerJoin(projects, eq(environments.projectId, projects.projectId))
|
||||||
|
.where(where),
|
||||||
|
]);
|
||||||
|
return { items, total: countResult[0]?.count ?? 0 };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -355,4 +355,13 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
active: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
if (!ctx.session.activeOrganizationId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await db.query.organization.findFirst({
|
||||||
|
where: eq(organization.id, ctx.session.activeOrganizationId),
|
||||||
|
});
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
deployPostgres,
|
deployPostgres,
|
||||||
findBackupsByDbId,
|
findBackupsByDbId,
|
||||||
findEnvironmentById,
|
findEnvironmentById,
|
||||||
|
findMemberById,
|
||||||
findPostgresById,
|
findPostgresById,
|
||||||
findProjectById,
|
findProjectById,
|
||||||
getMountPath,
|
getMountPath,
|
||||||
@@ -22,7 +23,7 @@ import {
|
|||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +38,7 @@ import {
|
|||||||
apiUpdatePostgres,
|
apiUpdatePostgres,
|
||||||
postgres as postgresTable,
|
postgres as postgresTable,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
|
import { environments, projects } from "@/server/db/schema";
|
||||||
import { cancelJobs } from "@/server/utils/backup";
|
import { cancelJobs } from "@/server/utils/backup";
|
||||||
|
|
||||||
export const postgresRouter = createTRPCRouter({
|
export const postgresRouter = createTRPCRouter({
|
||||||
@@ -483,4 +485,104 @@ export const postgresRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
|
search: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
q: z.string().optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
appName: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
projectId: z.string().optional(),
|
||||||
|
environmentId: z.string().optional(),
|
||||||
|
limit: z.number().min(1).max(100).default(20),
|
||||||
|
offset: z.number().min(0).default(0),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const baseConditions = [
|
||||||
|
eq(projects.organizationId, ctx.session.activeOrganizationId),
|
||||||
|
];
|
||||||
|
if (input.projectId) {
|
||||||
|
baseConditions.push(eq(environments.projectId, input.projectId));
|
||||||
|
}
|
||||||
|
if (input.environmentId) {
|
||||||
|
baseConditions.push(
|
||||||
|
eq(postgresTable.environmentId, input.environmentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.q?.trim()) {
|
||||||
|
const term = `%${input.q.trim()}%`;
|
||||||
|
baseConditions.push(
|
||||||
|
or(
|
||||||
|
ilike(postgresTable.name, term),
|
||||||
|
ilike(postgresTable.appName, term),
|
||||||
|
ilike(postgresTable.description ?? "", term),
|
||||||
|
)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.name?.trim()) {
|
||||||
|
baseConditions.push(
|
||||||
|
ilike(postgresTable.name, `%${input.name.trim()}%`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.appName?.trim()) {
|
||||||
|
baseConditions.push(
|
||||||
|
ilike(postgresTable.appName, `%${input.appName.trim()}%`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.description?.trim()) {
|
||||||
|
baseConditions.push(
|
||||||
|
ilike(
|
||||||
|
postgresTable.description ?? "",
|
||||||
|
`%${input.description.trim()}%`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ctx.user.role === "member") {
|
||||||
|
const { accessedServices } = await findMemberById(
|
||||||
|
ctx.user.id,
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
);
|
||||||
|
if (accessedServices.length === 0) return { items: [], total: 0 };
|
||||||
|
baseConditions.push(
|
||||||
|
sql`${postgresTable.postgresId} IN (${sql.join(
|
||||||
|
accessedServices.map((id) => sql`${id}`),
|
||||||
|
sql`, `,
|
||||||
|
)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const where = and(...baseConditions);
|
||||||
|
const [items, countResult] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
postgresId: postgresTable.postgresId,
|
||||||
|
name: postgresTable.name,
|
||||||
|
appName: postgresTable.appName,
|
||||||
|
description: postgresTable.description,
|
||||||
|
environmentId: postgresTable.environmentId,
|
||||||
|
applicationStatus: postgresTable.applicationStatus,
|
||||||
|
createdAt: postgresTable.createdAt,
|
||||||
|
})
|
||||||
|
.from(postgresTable)
|
||||||
|
.innerJoin(
|
||||||
|
environments,
|
||||||
|
eq(postgresTable.environmentId, environments.environmentId),
|
||||||
|
)
|
||||||
|
.innerJoin(projects, eq(environments.projectId, projects.projectId))
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(postgresTable.createdAt))
|
||||||
|
.limit(input.limit)
|
||||||
|
.offset(input.offset),
|
||||||
|
db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(postgresTable)
|
||||||
|
.innerJoin(
|
||||||
|
environments,
|
||||||
|
eq(postgresTable.environmentId, environments.environmentId),
|
||||||
|
)
|
||||||
|
.innerJoin(projects, eq(environments.projectId, projects.projectId))
|
||||||
|
.where(where),
|
||||||
|
]);
|
||||||
|
return { items, total: countResult[0]?.count ?? 0 };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,10 +34,14 @@ import {
|
|||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { and, desc, eq, sql } from "drizzle-orm";
|
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
|
||||||
import type { AnyPgColumn } from "drizzle-orm/pg-core";
|
import type { AnyPgColumn } from "drizzle-orm/pg-core";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
import {
|
||||||
|
adminProcedure,
|
||||||
|
createTRPCRouter,
|
||||||
|
protectedProcedure,
|
||||||
|
} from "@/server/api/trpc";
|
||||||
import {
|
import {
|
||||||
apiCreateProject,
|
apiCreateProject,
|
||||||
apiFindOneProject,
|
apiFindOneProject,
|
||||||
@@ -219,31 +223,69 @@ export const projectRouter = createTRPCRouter({
|
|||||||
applications.applicationId,
|
applications.applicationId,
|
||||||
accessedServices,
|
accessedServices,
|
||||||
),
|
),
|
||||||
with: { domains: true },
|
columns: {
|
||||||
|
applicationId: true,
|
||||||
|
name: true,
|
||||||
|
applicationStatus: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mariadb: {
|
mariadb: {
|
||||||
where: buildServiceFilter(mariadb.mariadbId, accessedServices),
|
where: buildServiceFilter(mariadb.mariadbId, accessedServices),
|
||||||
|
columns: {
|
||||||
|
mariadbId: true,
|
||||||
|
name: true,
|
||||||
|
applicationStatus: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mongo: {
|
mongo: {
|
||||||
where: buildServiceFilter(mongo.mongoId, accessedServices),
|
where: buildServiceFilter(mongo.mongoId, accessedServices),
|
||||||
|
columns: {
|
||||||
|
mongoId: true,
|
||||||
|
name: true,
|
||||||
|
applicationStatus: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mysql: {
|
mysql: {
|
||||||
where: buildServiceFilter(mysql.mysqlId, accessedServices),
|
where: buildServiceFilter(mysql.mysqlId, accessedServices),
|
||||||
|
columns: {
|
||||||
|
mysqlId: true,
|
||||||
|
name: true,
|
||||||
|
applicationStatus: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
postgres: {
|
postgres: {
|
||||||
where: buildServiceFilter(
|
where: buildServiceFilter(
|
||||||
postgres.postgresId,
|
postgres.postgresId,
|
||||||
accessedServices,
|
accessedServices,
|
||||||
),
|
),
|
||||||
|
columns: {
|
||||||
|
postgresId: true,
|
||||||
|
name: true,
|
||||||
|
applicationStatus: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
redis: {
|
redis: {
|
||||||
where: buildServiceFilter(redis.redisId, accessedServices),
|
where: buildServiceFilter(redis.redisId, accessedServices),
|
||||||
|
columns: {
|
||||||
|
redisId: true,
|
||||||
|
name: true,
|
||||||
|
applicationStatus: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
compose: {
|
compose: {
|
||||||
where: buildServiceFilter(compose.composeId, accessedServices),
|
where: buildServiceFilter(compose.composeId, accessedServices),
|
||||||
with: { domains: true },
|
columns: {
|
||||||
|
composeId: true,
|
||||||
|
name: true,
|
||||||
|
composeStatus: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
columns: {
|
||||||
|
environmentId: true,
|
||||||
|
isDefault: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: desc(projects.createdAt),
|
orderBy: desc(projects.createdAt),
|
||||||
@@ -255,21 +297,50 @@ export const projectRouter = createTRPCRouter({
|
|||||||
environments: {
|
environments: {
|
||||||
with: {
|
with: {
|
||||||
applications: {
|
applications: {
|
||||||
with: {
|
columns: {
|
||||||
domains: true,
|
applicationId: true,
|
||||||
|
name: true,
|
||||||
|
applicationStatus: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mariadb: {
|
||||||
|
columns: {
|
||||||
|
mariadbId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mongo: {
|
||||||
|
columns: {
|
||||||
|
mongoId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mysql: {
|
||||||
|
columns: {
|
||||||
|
mysqlId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
postgres: {
|
||||||
|
columns: {
|
||||||
|
postgresId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
columns: {
|
||||||
|
redisId: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mariadb: true,
|
|
||||||
mongo: true,
|
|
||||||
mysql: true,
|
|
||||||
postgres: true,
|
|
||||||
redis: true,
|
|
||||||
compose: {
|
compose: {
|
||||||
with: {
|
columns: {
|
||||||
domains: true,
|
composeId: true,
|
||||||
|
name: true,
|
||||||
|
composeStatus: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
columns: {
|
||||||
|
name: true,
|
||||||
|
environmentId: true,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
where: eq(projects.organizationId, ctx.session.activeOrganizationId),
|
where: eq(projects.organizationId, ctx.session.activeOrganizationId),
|
||||||
@@ -277,6 +348,183 @@ export const projectRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** All projects with full environments and services for the admin permissions UI. Admin only. */
|
||||||
|
allForPermissions: adminProcedure.query(async ({ ctx }) => {
|
||||||
|
return await db.query.projects.findMany({
|
||||||
|
where: eq(projects.organizationId, ctx.session.activeOrganizationId),
|
||||||
|
orderBy: desc(projects.createdAt),
|
||||||
|
columns: {
|
||||||
|
projectId: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
with: {
|
||||||
|
environments: {
|
||||||
|
columns: {
|
||||||
|
environmentId: true,
|
||||||
|
name: true,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
with: {
|
||||||
|
applications: {
|
||||||
|
columns: {
|
||||||
|
applicationId: true,
|
||||||
|
appName: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
applicationStatus: true,
|
||||||
|
description: true,
|
||||||
|
serverId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mariadb: {
|
||||||
|
columns: {
|
||||||
|
mariadbId: true,
|
||||||
|
appName: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
applicationStatus: true,
|
||||||
|
description: true,
|
||||||
|
serverId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
postgres: {
|
||||||
|
columns: {
|
||||||
|
postgresId: true,
|
||||||
|
appName: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
applicationStatus: true,
|
||||||
|
description: true,
|
||||||
|
serverId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mysql: {
|
||||||
|
columns: {
|
||||||
|
mysqlId: true,
|
||||||
|
appName: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
applicationStatus: true,
|
||||||
|
description: true,
|
||||||
|
serverId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mongo: {
|
||||||
|
columns: {
|
||||||
|
mongoId: true,
|
||||||
|
appName: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
applicationStatus: true,
|
||||||
|
description: true,
|
||||||
|
serverId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
columns: {
|
||||||
|
redisId: true,
|
||||||
|
appName: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
applicationStatus: true,
|
||||||
|
description: true,
|
||||||
|
serverId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
compose: {
|
||||||
|
columns: {
|
||||||
|
composeId: true,
|
||||||
|
appName: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
composeStatus: true,
|
||||||
|
description: true,
|
||||||
|
serverId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
search: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
q: z.string().optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
limit: z.number().min(1).max(100).default(20),
|
||||||
|
offset: z.number().min(0).default(0),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const baseConditions = [
|
||||||
|
eq(projects.organizationId, ctx.session.activeOrganizationId),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (input.q?.trim()) {
|
||||||
|
const term = `%${input.q.trim()}%`;
|
||||||
|
baseConditions.push(
|
||||||
|
or(
|
||||||
|
ilike(projects.name, term),
|
||||||
|
ilike(projects.description ?? "", term),
|
||||||
|
)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.name?.trim()) {
|
||||||
|
baseConditions.push(ilike(projects.name, `%${input.name.trim()}%`));
|
||||||
|
}
|
||||||
|
if (input.description?.trim()) {
|
||||||
|
baseConditions.push(
|
||||||
|
ilike(projects.description ?? "", `%${input.description.trim()}%`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.user.role === "member") {
|
||||||
|
const { accessedProjects } = await findMemberById(
|
||||||
|
ctx.user.id,
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
);
|
||||||
|
if (accessedProjects.length === 0) return { items: [], total: 0 };
|
||||||
|
baseConditions.push(
|
||||||
|
sql`${projects.projectId} IN (${sql.join(
|
||||||
|
accessedProjects.map((id) => sql`${id}`),
|
||||||
|
sql`, `,
|
||||||
|
)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = and(...baseConditions);
|
||||||
|
|
||||||
|
const [items, countResult] = await Promise.all([
|
||||||
|
db.query.projects.findMany({
|
||||||
|
where,
|
||||||
|
limit: input.limit,
|
||||||
|
offset: input.offset,
|
||||||
|
orderBy: desc(projects.createdAt),
|
||||||
|
columns: {
|
||||||
|
projectId: true,
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
createdAt: true,
|
||||||
|
organizationId: true,
|
||||||
|
env: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(projects)
|
||||||
|
.where(where),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total: countResult[0]?.count ?? 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
remove: protectedProcedure
|
remove: protectedProcedure
|
||||||
.input(apiRemoveProject)
|
.input(apiRemoveProject)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
|||||||
106
apps/dokploy/server/api/routers/proprietary/whitelabeling.ts
Normal file
106
apps/dokploy/server/api/routers/proprietary/whitelabeling.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
getWebServerSettings,
|
||||||
|
IS_CLOUD,
|
||||||
|
updateWebServerSettings,
|
||||||
|
} from "@dokploy/server";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { apiUpdateWhitelabeling } from "@/server/db/schema";
|
||||||
|
import {
|
||||||
|
createTRPCRouter,
|
||||||
|
enterpriseProcedure,
|
||||||
|
protectedProcedure,
|
||||||
|
publicProcedure,
|
||||||
|
} from "../../trpc";
|
||||||
|
|
||||||
|
export const whitelabelingRouter = createTRPCRouter({
|
||||||
|
get: protectedProcedure.query(async () => {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const settings = await getWebServerSettings();
|
||||||
|
return settings?.whitelabelingConfig ?? null;
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: enterpriseProcedure
|
||||||
|
.input(apiUpdateWhitelabeling)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Whitelabeling is not available in Cloud",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.user.role !== "owner") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Only the owner can update whitelabeling settings",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateWebServerSettings({
|
||||||
|
whitelabelingConfig: input.whitelabelingConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
reset: enterpriseProcedure.mutation(async ({ ctx }) => {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Whitelabeling is not available in Cloud",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.user.role !== "owner") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Only the owner can reset whitelabeling settings",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateWebServerSettings({
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Public endpoint only for unauthenticated pages (login, register, error)
|
||||||
|
// Returns only the fields needed for public pages
|
||||||
|
getPublic: publicProcedure.query(async () => {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const settings = await getWebServerSettings();
|
||||||
|
const config = settings?.whitelabelingConfig;
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
appName: config.appName,
|
||||||
|
appDescription: config.appDescription,
|
||||||
|
logoUrl: config.logoUrl,
|
||||||
|
loginLogoUrl: config.loginLogoUrl,
|
||||||
|
faviconUrl: config.faviconUrl,
|
||||||
|
customCss: config.customCss,
|
||||||
|
metaTitle: config.metaTitle,
|
||||||
|
errorPageTitle: config.errorPageTitle,
|
||||||
|
errorPageDescription: config.errorPageDescription,
|
||||||
|
footerText: config.footerText,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
createRedis,
|
createRedis,
|
||||||
deployRedis,
|
deployRedis,
|
||||||
findEnvironmentById,
|
findEnvironmentById,
|
||||||
|
findMemberById,
|
||||||
findProjectById,
|
findProjectById,
|
||||||
findRedisById,
|
findRedisById,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
@@ -20,7 +21,7 @@ import {
|
|||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
apiUpdateRedis,
|
apiUpdateRedis,
|
||||||
redis as redisTable,
|
redis as redisTable,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
|
import { environments, projects } from "@/server/db/schema";
|
||||||
export const redisRouter = createTRPCRouter({
|
export const redisRouter = createTRPCRouter({
|
||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
.input(apiCreateRedis)
|
.input(apiCreateRedis)
|
||||||
@@ -450,4 +452,97 @@ export const redisRouter = createTRPCRouter({
|
|||||||
await rebuildDatabase(redis.redisId, "redis");
|
await rebuildDatabase(redis.redisId, "redis");
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
|
search: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
q: z.string().optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
appName: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
projectId: z.string().optional(),
|
||||||
|
environmentId: z.string().optional(),
|
||||||
|
limit: z.number().min(1).max(100).default(20),
|
||||||
|
offset: z.number().min(0).default(0),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const baseConditions = [
|
||||||
|
eq(projects.organizationId, ctx.session.activeOrganizationId),
|
||||||
|
];
|
||||||
|
if (input.projectId) {
|
||||||
|
baseConditions.push(eq(environments.projectId, input.projectId));
|
||||||
|
}
|
||||||
|
if (input.environmentId) {
|
||||||
|
baseConditions.push(eq(redisTable.environmentId, input.environmentId));
|
||||||
|
}
|
||||||
|
if (input.q?.trim()) {
|
||||||
|
const term = `%${input.q.trim()}%`;
|
||||||
|
baseConditions.push(
|
||||||
|
or(
|
||||||
|
ilike(redisTable.name, term),
|
||||||
|
ilike(redisTable.appName, term),
|
||||||
|
ilike(redisTable.description ?? "", term),
|
||||||
|
)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.name?.trim()) {
|
||||||
|
baseConditions.push(ilike(redisTable.name, `%${input.name.trim()}%`));
|
||||||
|
}
|
||||||
|
if (input.appName?.trim()) {
|
||||||
|
baseConditions.push(
|
||||||
|
ilike(redisTable.appName, `%${input.appName.trim()}%`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.description?.trim()) {
|
||||||
|
baseConditions.push(
|
||||||
|
ilike(redisTable.description ?? "", `%${input.description.trim()}%`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ctx.user.role === "member") {
|
||||||
|
const { accessedServices } = await findMemberById(
|
||||||
|
ctx.user.id,
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
);
|
||||||
|
if (accessedServices.length === 0) return { items: [], total: 0 };
|
||||||
|
baseConditions.push(
|
||||||
|
sql`${redisTable.redisId} IN (${sql.join(
|
||||||
|
accessedServices.map((id) => sql`${id}`),
|
||||||
|
sql`, `,
|
||||||
|
)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const where = and(...baseConditions);
|
||||||
|
const [items, countResult] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
redisId: redisTable.redisId,
|
||||||
|
name: redisTable.name,
|
||||||
|
appName: redisTable.appName,
|
||||||
|
description: redisTable.description,
|
||||||
|
environmentId: redisTable.environmentId,
|
||||||
|
applicationStatus: redisTable.applicationStatus,
|
||||||
|
createdAt: redisTable.createdAt,
|
||||||
|
})
|
||||||
|
.from(redisTable)
|
||||||
|
.innerJoin(
|
||||||
|
environments,
|
||||||
|
eq(redisTable.environmentId, environments.environmentId),
|
||||||
|
)
|
||||||
|
.innerJoin(projects, eq(environments.projectId, projects.projectId))
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(redisTable.createdAt))
|
||||||
|
.limit(input.limit)
|
||||||
|
.offset(input.offset),
|
||||||
|
db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(redisTable)
|
||||||
|
.innerJoin(
|
||||||
|
environments,
|
||||||
|
eq(redisTable.environmentId, environments.environmentId),
|
||||||
|
)
|
||||||
|
.innerJoin(projects, eq(environments.projectId, projects.projectId))
|
||||||
|
.where(where),
|
||||||
|
]);
|
||||||
|
return { items, total: countResult[0]?.count ?? 0 };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -149,12 +149,12 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
// Check if port 8080 is already in use before enabling dashboard
|
// Check if port 8080 is already in use before enabling dashboard
|
||||||
const portCheck = await checkPortInUse(8080, input.serverId);
|
const portCheck = await checkPortInUse(8080, input.serverId);
|
||||||
if (portCheck.isInUse) {
|
if (portCheck.isInUse) {
|
||||||
const conflictingContainer = portCheck.conflictingContainer
|
const conflictInfo = portCheck.conflictingContainer
|
||||||
? ` by container "${portCheck.conflictingContainer}"`
|
? ` by ${portCheck.conflictingContainer}`
|
||||||
: "";
|
: "";
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "CONFLICT",
|
code: "CONFLICT",
|
||||||
message: `Port 8080 is already in use${conflictingContainer}. Please stop the conflicting service or use a different port for the Traefik dashboard.`,
|
message: `Port 8080 is already in use${conflictInfo}. Please stop the conflicting service or use a different port for the Traefik dashboard.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
newPorts.push({
|
newPorts.push({
|
||||||
@@ -530,12 +530,12 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
getOpenApiDocument: protectedProcedure.query(
|
getOpenApiDocument: protectedProcedure.query(
|
||||||
async ({ ctx }): Promise<unknown> => {
|
async ({ ctx }): Promise<unknown> => {
|
||||||
const protocol = ctx.req.headers["x-forwarded-proto"];
|
const protocol = ctx.req.headers["x-forwarded-proto"];
|
||||||
const url = `${protocol}://${ctx.req.headers.host}/api/trpc`;
|
const url = `${protocol}://${ctx.req.headers.host}/api`;
|
||||||
const openApiDocument = generateOpenApiDocument(appRouter, {
|
const openApiDocument = generateOpenApiDocument(appRouter, {
|
||||||
title: "tRPC OpenAPI",
|
title: "tRPC OpenAPI",
|
||||||
version: packageInfo.version,
|
version: packageInfo.version,
|
||||||
baseUrl: url,
|
baseUrl: url,
|
||||||
docsUrl: `${url}/trpc/settings.getOpenApiDocument`,
|
docsUrl: `${url}/settings.getOpenApiDocument`,
|
||||||
tags: [
|
tags: [
|
||||||
"admin",
|
"admin",
|
||||||
"docker",
|
"docker",
|
||||||
|
|||||||
@@ -101,6 +101,19 @@ export const userRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return memberResult;
|
return memberResult;
|
||||||
}),
|
}),
|
||||||
|
session: publicProcedure.query(async ({ ctx }) => {
|
||||||
|
if (!ctx.user || !ctx.session || !ctx.session.activeOrganizationId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: ctx.user.id,
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
activeOrganizationId: ctx.session.activeOrganizationId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
get: protectedProcedure.query(async ({ ctx }) => {
|
get: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const memberResult = await db.query.member.findFirst({
|
const memberResult = await db.query.member.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
|
|||||||
38
apps/dokploy/server/db/index.ts
Normal file
38
apps/dokploy/server/db/index.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { dbUrl } from "@dokploy/server/db/constants";
|
||||||
|
import * as schema from "@dokploy/server/db/schema";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||||
|
import postgres from "postgres";
|
||||||
|
|
||||||
|
export { and, eq };
|
||||||
|
|
||||||
|
type Database = PostgresJsDatabase<typeof schema>;
|
||||||
|
/**
|
||||||
|
* Evita problemas de redeclaración global en monorepos.
|
||||||
|
* No usamos `declare global`.
|
||||||
|
*/
|
||||||
|
const globalForDb = globalThis as unknown as {
|
||||||
|
db?: Database;
|
||||||
|
};
|
||||||
|
|
||||||
|
let dbConnection: Database;
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
// En producción no usamos global cache
|
||||||
|
dbConnection = drizzle(postgres(dbUrl), {
|
||||||
|
schema,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// En desarrollo reutilizamos conexión para evitar múltiples conexiones
|
||||||
|
if (!globalForDb.db) {
|
||||||
|
globalForDb.db = drizzle(postgres(dbUrl), {
|
||||||
|
schema,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dbConnection = globalForDb.db;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const db: Database = dbConnection;
|
||||||
|
|
||||||
|
export { dbUrl };
|
||||||
@@ -50,3 +50,34 @@ export const cancelDeployment = async (cancelData: CancelDeploymentData) => {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QueueJobRow = {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
timestamp?: number;
|
||||||
|
processedOn?: number;
|
||||||
|
finishedOn?: number;
|
||||||
|
failedReason?: string;
|
||||||
|
state: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchDeployApiJobs = async (
|
||||||
|
serverId: string,
|
||||||
|
): Promise<QueueJobRow[]> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.SERVER_URL}/jobs?serverId=${encodeURIComponent(serverId)}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-API-Key": process.env.API_KEY || "NO-DEFINED",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
return (await res.json()) as QueueJobRow[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
25
apps/dokploy/utils/hooks/use-whitelabeling.ts
Normal file
25
apps/dokploy/utils/hooks/use-whitelabeling.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access whitelabeling config for authenticated pages (dashboard, services, etc.).
|
||||||
|
* Requires the user to be logged in.
|
||||||
|
*/
|
||||||
|
export function useWhitelabeling() {
|
||||||
|
const { data, ...rest } = api.whitelabeling.get.useQuery(undefined, {
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
return { config: data ?? null, ...rest };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access the public whitelabeling config.
|
||||||
|
* Only for unauthenticated pages (login, register, error, invitation, password reset).
|
||||||
|
*/
|
||||||
|
export function useWhitelabelingPublic() {
|
||||||
|
const { data, ...rest } = api.whitelabeling.getPublic.useQuery(undefined, {
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
return { config: data ?? null, ...rest };
|
||||||
|
}
|
||||||
@@ -54,13 +54,13 @@ func (db *DB) GetLastNContainerMetrics(containerName string, limit int) ([]Conta
|
|||||||
WITH recent_metrics AS (
|
WITH recent_metrics AS (
|
||||||
SELECT metrics_json
|
SELECT metrics_json
|
||||||
FROM container_metrics
|
FROM container_metrics
|
||||||
WHERE container_name = ?
|
WHERE container_name = ? OR container_name LIKE ?
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
)
|
)
|
||||||
SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC
|
SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC
|
||||||
`
|
`
|
||||||
rows, err := db.Query(query, containerName, limit)
|
rows, err := db.Query(query, containerName, containerName+".%", limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -90,12 +90,12 @@ func (db *DB) GetAllMetricsContainer(containerName string) ([]ContainerMetric, e
|
|||||||
WITH recent_metrics AS (
|
WITH recent_metrics AS (
|
||||||
SELECT metrics_json
|
SELECT metrics_json
|
||||||
FROM container_metrics
|
FROM container_metrics
|
||||||
WHERE container_name = ?
|
WHERE container_name = ? OR container_name LIKE ?
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
)
|
)
|
||||||
SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC
|
SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC
|
||||||
`
|
`
|
||||||
rows, err := db.Query(query, containerName)
|
rows, err := db.Query(query, containerName, containerName+".%")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,25 @@ import path from "node:path";
|
|||||||
import Docker from "dockerode";
|
import Docker from "dockerode";
|
||||||
|
|
||||||
export const IS_CLOUD = process.env.IS_CLOUD === "true";
|
export const IS_CLOUD = process.env.IS_CLOUD === "true";
|
||||||
|
export const DOKPLOY_DOCKER_API_VERSION =
|
||||||
|
process.env.DOKPLOY_DOCKER_API_VERSION;
|
||||||
|
export const DOKPLOY_DOCKER_HOST = process.env.DOKPLOY_DOCKER_HOST;
|
||||||
|
export const DOKPLOY_DOCKER_PORT = process.env.DOKPLOY_DOCKER_PORT
|
||||||
|
? Number(process.env.DOKPLOY_DOCKER_PORT)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
export const CLEANUP_CRON_JOB = "50 23 * * *";
|
export const CLEANUP_CRON_JOB = "50 23 * * *";
|
||||||
export const docker = new Docker();
|
export const docker = new Docker({
|
||||||
|
...(DOKPLOY_DOCKER_API_VERSION && {
|
||||||
|
version: DOKPLOY_DOCKER_API_VERSION,
|
||||||
|
}),
|
||||||
|
...(DOKPLOY_DOCKER_HOST && {
|
||||||
|
host: DOKPLOY_DOCKER_HOST,
|
||||||
|
}),
|
||||||
|
...(DOKPLOY_DOCKER_PORT && {
|
||||||
|
port: DOKPLOY_DOCKER_PORT,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
// When not set, use the legacy default so 2FA remains working for users who
|
// When not set, use the legacy default so 2FA remains working for users who
|
||||||
// enabled it before BETTER_AUTH_SECRET was introduced .
|
// enabled it before BETTER_AUTH_SECRET was introduced .
|
||||||
|
|||||||
@@ -365,12 +365,13 @@ const createSchema = createInsertSchema(applications, {
|
|||||||
previewPath: z.string().optional(),
|
previewPath: z.string().optional(),
|
||||||
previewCertificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
previewCertificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
||||||
previewRequireCollaboratorPermissions: z.boolean().optional(),
|
previewRequireCollaboratorPermissions: z.boolean().optional(),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional().optional(),
|
||||||
previewLabels: z.array(z.string()).optional(),
|
previewLabels: z.array(z.string()).optional(),
|
||||||
cleanCache: z.boolean().optional(),
|
cleanCache: z.boolean().optional(),
|
||||||
stopGracePeriodSwarm: z.bigint().nullable(),
|
stopGracePeriodSwarm: z.bigint().nullable(),
|
||||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||||
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
|
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
|
||||||
|
enableSubmodules: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiCreateApplication = createSchema.pick({
|
export const apiCreateApplication = createSchema.pick({
|
||||||
@@ -433,13 +434,13 @@ export const apiSaveGithubProvider = createSchema
|
|||||||
owner: true,
|
owner: true,
|
||||||
buildPath: true,
|
buildPath: true,
|
||||||
githubId: true,
|
githubId: true,
|
||||||
watchPaths: true,
|
|
||||||
enableSubmodules: true,
|
|
||||||
})
|
})
|
||||||
.required()
|
.required()
|
||||||
.extend({
|
.extend({
|
||||||
triggerType: z.enum(["push", "tag"]).default("push"),
|
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||||
});
|
})
|
||||||
|
.required()
|
||||||
|
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
|
||||||
|
|
||||||
export const apiSaveGitlabProvider = createSchema
|
export const apiSaveGitlabProvider = createSchema
|
||||||
.pick({
|
.pick({
|
||||||
@@ -451,10 +452,9 @@ export const apiSaveGitlabProvider = createSchema
|
|||||||
gitlabId: true,
|
gitlabId: true,
|
||||||
gitlabProjectId: true,
|
gitlabProjectId: true,
|
||||||
gitlabPathNamespace: true,
|
gitlabPathNamespace: true,
|
||||||
watchPaths: true,
|
|
||||||
enableSubmodules: true,
|
|
||||||
})
|
})
|
||||||
.required();
|
.required()
|
||||||
|
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
|
||||||
|
|
||||||
export const apiSaveBitbucketProvider = createSchema
|
export const apiSaveBitbucketProvider = createSchema
|
||||||
.pick({
|
.pick({
|
||||||
@@ -465,10 +465,9 @@ export const apiSaveBitbucketProvider = createSchema
|
|||||||
bitbucketRepositorySlug: true,
|
bitbucketRepositorySlug: true,
|
||||||
bitbucketId: true,
|
bitbucketId: true,
|
||||||
applicationId: true,
|
applicationId: true,
|
||||||
watchPaths: true,
|
|
||||||
enableSubmodules: true,
|
|
||||||
})
|
})
|
||||||
.required();
|
.required()
|
||||||
|
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
|
||||||
|
|
||||||
export const apiSaveGiteaProvider = createSchema
|
export const apiSaveGiteaProvider = createSchema
|
||||||
.pick({
|
.pick({
|
||||||
@@ -478,10 +477,9 @@ export const apiSaveGiteaProvider = createSchema
|
|||||||
giteaOwner: true,
|
giteaOwner: true,
|
||||||
giteaRepository: true,
|
giteaRepository: true,
|
||||||
giteaId: true,
|
giteaId: true,
|
||||||
watchPaths: true,
|
|
||||||
enableSubmodules: true,
|
|
||||||
})
|
})
|
||||||
.required();
|
.required()
|
||||||
|
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
|
||||||
|
|
||||||
export const apiSaveDockerProvider = createSchema
|
export const apiSaveDockerProvider = createSchema
|
||||||
.pick({
|
.pick({
|
||||||
@@ -506,6 +504,7 @@ export const apiSaveGitProvider = createSchema
|
|||||||
.merge(
|
.merge(
|
||||||
createSchema.pick({
|
createSchema.pick({
|
||||||
customGitSSHKeyId: true,
|
customGitSSHKeyId: true,
|
||||||
|
enableSubmodules: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -99,17 +99,15 @@ const createSchema = createInsertSchema(mounts, {
|
|||||||
mountPath: z.string().min(1),
|
mountPath: z.string().min(1),
|
||||||
mountId: z.string().optional(),
|
mountId: z.string().optional(),
|
||||||
filePath: z.string().optional(),
|
filePath: z.string().optional(),
|
||||||
serviceType: z
|
serviceType: z.enum([
|
||||||
.enum([
|
"application",
|
||||||
"application",
|
"postgres",
|
||||||
"postgres",
|
"mysql",
|
||||||
"mysql",
|
"mariadb",
|
||||||
"mariadb",
|
"mongo",
|
||||||
"mongo",
|
"redis",
|
||||||
"redis",
|
"compose",
|
||||||
"compose",
|
]),
|
||||||
])
|
|
||||||
.default("application"),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ServiceType = NonNullable<
|
export type ServiceType = NonNullable<
|
||||||
|
|||||||
@@ -66,6 +66,36 @@ export const webServerSettings = pgTable("webServerSettings", {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
// Whitelabeling Configuration (Enterprise / Proprietary)
|
||||||
|
whitelabelingConfig: jsonb("whitelabelingConfig")
|
||||||
|
.$type<{
|
||||||
|
appName: string | null;
|
||||||
|
appDescription: string | null;
|
||||||
|
logoUrl: string | null;
|
||||||
|
faviconUrl: string | null;
|
||||||
|
customCss: string | null;
|
||||||
|
loginLogoUrl: string | null;
|
||||||
|
supportUrl: string | null;
|
||||||
|
docsUrl: string | null;
|
||||||
|
errorPageTitle: string | null;
|
||||||
|
errorPageDescription: string | null;
|
||||||
|
metaTitle: string | null;
|
||||||
|
footerText: string | null;
|
||||||
|
}>()
|
||||||
|
.default({
|
||||||
|
appName: null,
|
||||||
|
appDescription: null,
|
||||||
|
logoUrl: null,
|
||||||
|
faviconUrl: null,
|
||||||
|
customCss: null,
|
||||||
|
loginLogoUrl: null,
|
||||||
|
supportUrl: null,
|
||||||
|
docsUrl: null,
|
||||||
|
errorPageTitle: null,
|
||||||
|
errorPageDescription: null,
|
||||||
|
metaTitle: null,
|
||||||
|
footerText: null,
|
||||||
|
}),
|
||||||
// Cache Cleanup Configuration
|
// Cache Cleanup Configuration
|
||||||
cleanupCacheApplications: boolean("cleanupCacheApplications")
|
cleanupCacheApplications: boolean("cleanupCacheApplications")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -154,6 +184,33 @@ export const apiUpdateDockerCleanup = z.object({
|
|||||||
serverId: z.string().optional(),
|
serverId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Whitelabeling validation schemas
|
||||||
|
const safeUrl = z
|
||||||
|
.string()
|
||||||
|
.refine((url) => /^https?:\/\//i.test(url), {
|
||||||
|
message: "Only http:// and https:// URLs are allowed",
|
||||||
|
})
|
||||||
|
.nullable();
|
||||||
|
|
||||||
|
export const whitelabelingConfigSchema = z.object({
|
||||||
|
appName: z.string().nullable(),
|
||||||
|
appDescription: z.string().nullable(),
|
||||||
|
logoUrl: safeUrl,
|
||||||
|
faviconUrl: safeUrl,
|
||||||
|
customCss: z.string().nullable(),
|
||||||
|
loginLogoUrl: safeUrl,
|
||||||
|
supportUrl: safeUrl,
|
||||||
|
docsUrl: safeUrl,
|
||||||
|
errorPageTitle: z.string().nullable(),
|
||||||
|
errorPageDescription: z.string().nullable(),
|
||||||
|
metaTitle: z.string().nullable(),
|
||||||
|
footerText: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiUpdateWhitelabeling = z.object({
|
||||||
|
whitelabelingConfig: whitelabelingConfigSchema,
|
||||||
|
});
|
||||||
|
|
||||||
export const apiUpdateWebServerMonitoring = z.object({
|
export const apiUpdateWebServerMonitoring = z.object({
|
||||||
metricsConfig: z
|
metricsConfig: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -73,23 +73,25 @@ const { handler, api } = betterAuth({
|
|||||||
disabled: process.env.NODE_ENV === "production",
|
disabled: process.env.NODE_ENV === "production",
|
||||||
},
|
},
|
||||||
async trustedOrigins() {
|
async trustedOrigins() {
|
||||||
const trustedOrigins = await getTrustedOrigins();
|
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return trustedOrigins;
|
return getTrustedOrigins();
|
||||||
}
|
}
|
||||||
const settings = await getWebServerSettings();
|
const [trustedOrigins, settings] = await Promise.all([
|
||||||
if (!settings) {
|
getTrustedOrigins(),
|
||||||
return [];
|
getWebServerSettings(),
|
||||||
}
|
]);
|
||||||
return [
|
if (!settings) return [];
|
||||||
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
|
const devOrigins =
|
||||||
...(settings?.host ? [`https://${settings?.host}`] : []),
|
process.env.NODE_ENV === "development"
|
||||||
...(process.env.NODE_ENV === "development"
|
|
||||||
? [
|
? [
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"https://absolutely-handy-falcon.ngrok-free.app",
|
"https://absolutely-handy-falcon.ngrok-free.app",
|
||||||
]
|
]
|
||||||
: []),
|
: [];
|
||||||
|
return [
|
||||||
|
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
|
||||||
|
...(settings?.host ? [`https://${settings?.host}`] : []),
|
||||||
|
...devOrigins,
|
||||||
...trustedOrigins,
|
...trustedOrigins,
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -117,23 +117,43 @@ export const getDokployUrl = async () => {
|
|||||||
return `http://${settings?.serverIp}:${process.env.PORT}`;
|
return `http://${settings?.serverIp}:${process.env.PORT}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getTrustedOrigins = async () => {
|
const TRUSTED_ORIGINS_CACHE_TTL_MS = 30 * 60_000;
|
||||||
const members = await db.query.member.findMany({
|
let trustedOriginsCache: { data: string[]; expiresAt: number } | null = null;
|
||||||
where: eq(member.role, "owner"),
|
|
||||||
with: {
|
|
||||||
user: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (members.length === 0) {
|
export const getTrustedOrigins = async () => {
|
||||||
return [];
|
const runQuery = async () => {
|
||||||
|
const rows = await db
|
||||||
|
.select({ trustedOrigins: user.trustedOrigins })
|
||||||
|
.from(member)
|
||||||
|
.innerJoin(user, eq(member.userId, user.id))
|
||||||
|
.where(eq(member.role, "owner"));
|
||||||
|
return Array.from(new Set(rows.flatMap((r) => r.trustedOrigins ?? [])));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (trustedOriginsCache && now < trustedOriginsCache.expiresAt) {
|
||||||
|
return trustedOriginsCache.data;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const trustedOrigins = await runQuery();
|
||||||
|
trustedOriginsCache = {
|
||||||
|
data: trustedOrigins,
|
||||||
|
expiresAt: now + TRUSTED_ORIGINS_CACHE_TTL_MS,
|
||||||
|
};
|
||||||
|
return trustedOrigins;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch trusted origins:", error);
|
||||||
|
return trustedOriginsCache?.data ?? [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const trustedOrigins = members.flatMap(
|
try {
|
||||||
(member) => member.user.trustedOrigins || [],
|
return await runQuery();
|
||||||
);
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch trusted origins:", error);
|
||||||
return Array.from(new Set(trustedOrigins));
|
return [];
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getTrustedProviders = async () => {
|
export const getTrustedProviders = async () => {
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import {
|
|||||||
type apiCreateDeploymentSchedule,
|
type apiCreateDeploymentSchedule,
|
||||||
type apiCreateDeploymentServer,
|
type apiCreateDeploymentServer,
|
||||||
type apiCreateDeploymentVolumeBackup,
|
type apiCreateDeploymentVolumeBackup,
|
||||||
|
applications,
|
||||||
|
compose,
|
||||||
deployments,
|
deployments,
|
||||||
|
environments,
|
||||||
|
projects,
|
||||||
} from "@dokploy/server/db/schema";
|
} from "@dokploy/server/db/schema";
|
||||||
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
|
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
|
||||||
import {
|
import {
|
||||||
@@ -19,7 +23,7 @@ import {
|
|||||||
} from "@dokploy/server/utils/process/execAsync";
|
} from "@dokploy/server/utils/process/execAsync";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq, and, inArray, or, sql } from "drizzle-orm";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import {
|
import {
|
||||||
type Application,
|
type Application,
|
||||||
@@ -38,6 +42,41 @@ import { findScheduleById } from "./schedule";
|
|||||||
import { findServerById, type Server } from "./server";
|
import { findServerById, type Server } from "./server";
|
||||||
import { findVolumeBackupById } from "./volume-backups";
|
import { findVolumeBackupById } from "./volume-backups";
|
||||||
|
|
||||||
|
export type ServicePath = { href: string | null; label: string };
|
||||||
|
|
||||||
|
export async function resolveServicePath(
|
||||||
|
orgId: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): Promise<ServicePath> {
|
||||||
|
try {
|
||||||
|
const applicationId = data?.applicationId as string | undefined;
|
||||||
|
const composeId = data?.composeId as string | undefined;
|
||||||
|
if (applicationId) {
|
||||||
|
const app = await findApplicationById(applicationId);
|
||||||
|
if (app.environment.project.organizationId !== orgId) {
|
||||||
|
return { href: null, label: "Application" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
|
||||||
|
label: "Application",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (composeId) {
|
||||||
|
const comp = await findComposeById(composeId);
|
||||||
|
if (comp.environment.project.organizationId !== orgId) {
|
||||||
|
return { href: null, label: "Compose" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
|
||||||
|
label: "Compose",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// not found or unauthorized
|
||||||
|
}
|
||||||
|
return { href: null, label: "—" };
|
||||||
|
}
|
||||||
|
|
||||||
export type Deployment = typeof deployments.$inferSelect;
|
export type Deployment = typeof deployments.$inferSelect;
|
||||||
|
|
||||||
export const findDeploymentById = async (deploymentId: string) => {
|
export const findDeploymentById = async (deploymentId: string) => {
|
||||||
@@ -78,12 +117,12 @@ export const createDeployment = async (
|
|||||||
>,
|
>,
|
||||||
) => {
|
) => {
|
||||||
const application = await findApplicationById(deployment.applicationId);
|
const application = await findApplicationById(deployment.applicationId);
|
||||||
|
await removeLastTenDeployments(
|
||||||
|
deployment.applicationId,
|
||||||
|
"application",
|
||||||
|
application.serverId,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await removeLastTenDeployments(
|
|
||||||
deployment.applicationId,
|
|
||||||
"application",
|
|
||||||
application.serverId,
|
|
||||||
);
|
|
||||||
const serverId = application.buildServerId || application.serverId;
|
const serverId = application.buildServerId || application.serverId;
|
||||||
|
|
||||||
const { LOGS_PATH } = paths(!!serverId);
|
const { LOGS_PATH } = paths(!!serverId);
|
||||||
@@ -161,13 +200,12 @@ export const createDeploymentPreview = async (
|
|||||||
const previewDeployment = await findPreviewDeploymentById(
|
const previewDeployment = await findPreviewDeploymentById(
|
||||||
deployment.previewDeploymentId,
|
deployment.previewDeploymentId,
|
||||||
);
|
);
|
||||||
|
await removeLastTenDeployments(
|
||||||
|
deployment.previewDeploymentId,
|
||||||
|
"previewDeployment",
|
||||||
|
previewDeployment?.application?.serverId,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await removeLastTenDeployments(
|
|
||||||
deployment.previewDeploymentId,
|
|
||||||
"previewDeployment",
|
|
||||||
previewDeployment?.application?.serverId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const appName = `${previewDeployment.appName}`;
|
const appName = `${previewDeployment.appName}`;
|
||||||
const { LOGS_PATH } = paths(!!previewDeployment?.application?.serverId);
|
const { LOGS_PATH } = paths(!!previewDeployment?.application?.serverId);
|
||||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||||
@@ -242,12 +280,12 @@ export const createDeploymentCompose = async (
|
|||||||
>,
|
>,
|
||||||
) => {
|
) => {
|
||||||
const compose = await findComposeById(deployment.composeId);
|
const compose = await findComposeById(deployment.composeId);
|
||||||
|
await removeLastTenDeployments(
|
||||||
|
deployment.composeId,
|
||||||
|
"compose",
|
||||||
|
compose.serverId,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await removeLastTenDeployments(
|
|
||||||
deployment.composeId,
|
|
||||||
"compose",
|
|
||||||
compose.serverId,
|
|
||||||
);
|
|
||||||
const { LOGS_PATH } = paths(!!compose.serverId);
|
const { LOGS_PATH } = paths(!!compose.serverId);
|
||||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||||
const fileName = `${compose.appName}-${formattedDateTime}.log`;
|
const fileName = `${compose.appName}-${formattedDateTime}.log`;
|
||||||
@@ -330,8 +368,8 @@ export const createDeploymentBackup = async (
|
|||||||
} else if (backup.backupType === "compose") {
|
} else if (backup.backupType === "compose") {
|
||||||
serverId = backup.compose?.serverId;
|
serverId = backup.compose?.serverId;
|
||||||
}
|
}
|
||||||
|
await removeLastTenDeployments(deployment.backupId, "backup", serverId);
|
||||||
try {
|
try {
|
||||||
await removeLastTenDeployments(deployment.backupId, "backup", serverId);
|
|
||||||
const { LOGS_PATH } = paths(!!serverId);
|
const { LOGS_PATH } = paths(!!serverId);
|
||||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||||
const fileName = `${backup.appName}-${formattedDateTime}.log`;
|
const fileName = `${backup.appName}-${formattedDateTime}.log`;
|
||||||
@@ -400,12 +438,12 @@ export const createDeploymentSchedule = async (
|
|||||||
) => {
|
) => {
|
||||||
const schedule = await findScheduleById(deployment.scheduleId);
|
const schedule = await findScheduleById(deployment.scheduleId);
|
||||||
|
|
||||||
|
const serverId =
|
||||||
|
schedule.application?.serverId ||
|
||||||
|
schedule.compose?.serverId ||
|
||||||
|
schedule.server?.serverId;
|
||||||
|
await removeLastTenDeployments(deployment.scheduleId, "schedule", serverId);
|
||||||
try {
|
try {
|
||||||
const serverId =
|
|
||||||
schedule.application?.serverId ||
|
|
||||||
schedule.compose?.serverId ||
|
|
||||||
schedule.server?.serverId;
|
|
||||||
await removeLastTenDeployments(deployment.scheduleId, "schedule", serverId);
|
|
||||||
const { SCHEDULES_PATH } = paths(!!serverId);
|
const { SCHEDULES_PATH } = paths(!!serverId);
|
||||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||||
const fileName = `${schedule.appName}-${formattedDateTime}.log`;
|
const fileName = `${schedule.appName}-${formattedDateTime}.log`;
|
||||||
@@ -476,14 +514,14 @@ export const createDeploymentVolumeBackup = async (
|
|||||||
) => {
|
) => {
|
||||||
const volumeBackup = await findVolumeBackupById(deployment.volumeBackupId);
|
const volumeBackup = await findVolumeBackupById(deployment.volumeBackupId);
|
||||||
|
|
||||||
|
const serverId =
|
||||||
|
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
|
||||||
|
await removeLastTenDeployments(
|
||||||
|
deployment.volumeBackupId,
|
||||||
|
"volumeBackup",
|
||||||
|
serverId,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const serverId =
|
|
||||||
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
|
|
||||||
await removeLastTenDeployments(
|
|
||||||
deployment.volumeBackupId,
|
|
||||||
"volumeBackup",
|
|
||||||
serverId,
|
|
||||||
);
|
|
||||||
const { VOLUME_BACKUPS_PATH } = paths(!!serverId);
|
const { VOLUME_BACKUPS_PATH } = paths(!!serverId);
|
||||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||||
const fileName = `${volumeBackup.appName}-${formattedDateTime}.log`;
|
const fileName = `${volumeBackup.appName}-${formattedDateTime}.log`;
|
||||||
@@ -562,24 +600,23 @@ export const removeDeployment = async (deploymentId: string) => {
|
|||||||
.then((result) => result[0]);
|
.then((result) => result[0]);
|
||||||
|
|
||||||
if (!deployment) {
|
if (!deployment) {
|
||||||
throw new TRPCError({
|
return null;
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "Deployment not found",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const command = `
|
|
||||||
rm -f ${deployment.logPath};
|
const logPath = path.join(deployment.logPath);
|
||||||
`;
|
if (logPath && logPath !== ".") {
|
||||||
if (deployment.serverId) {
|
const command = `rm -f ${logPath};`;
|
||||||
await execAsyncRemote(deployment.serverId, command);
|
if (deployment.serverId) {
|
||||||
} else {
|
await execAsyncRemote(deployment.serverId, command);
|
||||||
await execAsync(command);
|
} else {
|
||||||
|
await execAsync(command);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return deployment;
|
return deployment;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : "Error creating the deployment";
|
error instanceof Error ? error.message : "Error removing the deployment";
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message,
|
message,
|
||||||
@@ -647,34 +684,49 @@ const removeLastTenDeployments = async (
|
|||||||
if (serverId) {
|
if (serverId) {
|
||||||
let command = "";
|
let command = "";
|
||||||
for (const oldDeployment of deploymentsToDelete) {
|
for (const oldDeployment of deploymentsToDelete) {
|
||||||
const logPath = path.join(oldDeployment.logPath);
|
try {
|
||||||
if (oldDeployment.rollbackId) {
|
const logPath = path.join(oldDeployment.logPath);
|
||||||
await removeRollbackById(oldDeployment.rollbackId);
|
if (oldDeployment.rollbackId) {
|
||||||
}
|
await removeRollbackById(oldDeployment.rollbackId);
|
||||||
|
}
|
||||||
|
|
||||||
if (logPath !== ".") {
|
if (logPath && logPath !== ".") {
|
||||||
command += `
|
command += `rm -rf ${logPath};`;
|
||||||
rm -rf ${logPath};
|
}
|
||||||
`;
|
await removeDeployment(oldDeployment.deploymentId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`Failed to remove deployment ${oldDeployment.deploymentId} during cleanup:`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await removeDeployment(oldDeployment.deploymentId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await execAsyncRemote(serverId, command);
|
if (command) {
|
||||||
|
await execAsyncRemote(serverId, command);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
for (const oldDeployment of deploymentsToDelete) {
|
for (const oldDeployment of deploymentsToDelete) {
|
||||||
if (oldDeployment.rollbackId) {
|
try {
|
||||||
await removeRollbackById(oldDeployment.rollbackId);
|
if (oldDeployment.rollbackId) {
|
||||||
|
await removeRollbackById(oldDeployment.rollbackId);
|
||||||
|
}
|
||||||
|
const logPath = path.join(oldDeployment.logPath);
|
||||||
|
if (
|
||||||
|
logPath &&
|
||||||
|
logPath !== "." &&
|
||||||
|
existsSync(logPath) &&
|
||||||
|
!oldDeployment.errorMessage
|
||||||
|
) {
|
||||||
|
await fsPromises.unlink(logPath);
|
||||||
|
}
|
||||||
|
await removeDeployment(oldDeployment.deploymentId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`Failed to remove deployment ${oldDeployment.deploymentId} during cleanup:`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const logPath = path.join(oldDeployment.logPath);
|
|
||||||
if (
|
|
||||||
existsSync(logPath) &&
|
|
||||||
!oldDeployment.errorMessage &&
|
|
||||||
logPath !== "."
|
|
||||||
) {
|
|
||||||
await fsPromises.unlink(logPath);
|
|
||||||
}
|
|
||||||
await removeDeployment(oldDeployment.deploymentId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -738,6 +790,135 @@ export const findAllDeploymentsByComposeId = async (composeId: string) => {
|
|||||||
return deploymentsList;
|
return deploymentsList;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const centralizedDeploymentsWith = {
|
||||||
|
application: {
|
||||||
|
columns: { applicationId: true, name: true, appName: true },
|
||||||
|
with: {
|
||||||
|
environment: {
|
||||||
|
columns: { environmentId: true, name: true },
|
||||||
|
with: {
|
||||||
|
project: {
|
||||||
|
columns: { projectId: true, name: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
columns: { serverId: true, name: true, serverType: true },
|
||||||
|
},
|
||||||
|
buildServer: {
|
||||||
|
columns: { serverId: true, name: true, serverType: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
compose: {
|
||||||
|
columns: { composeId: true, name: true, appName: true },
|
||||||
|
with: {
|
||||||
|
environment: {
|
||||||
|
columns: { environmentId: true, name: true },
|
||||||
|
with: {
|
||||||
|
project: {
|
||||||
|
columns: { projectId: true, name: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
columns: { serverId: true, name: true, serverType: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
columns: { serverId: true, name: true, serverType: true },
|
||||||
|
},
|
||||||
|
buildServer: {
|
||||||
|
columns: { serverId: true, name: true, serverType: true },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
async function getApplicationIdsInOrg(
|
||||||
|
orgId: string,
|
||||||
|
accessedServices: string[] | null,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ applicationId: applications.applicationId })
|
||||||
|
.from(applications)
|
||||||
|
.innerJoin(
|
||||||
|
environments,
|
||||||
|
eq(applications.environmentId, environments.environmentId),
|
||||||
|
)
|
||||||
|
.innerJoin(projects, eq(environments.projectId, projects.projectId))
|
||||||
|
.where(
|
||||||
|
accessedServices !== null
|
||||||
|
? and(
|
||||||
|
eq(projects.organizationId, orgId),
|
||||||
|
inArray(applications.applicationId, accessedServices),
|
||||||
|
)
|
||||||
|
: eq(projects.organizationId, orgId),
|
||||||
|
);
|
||||||
|
return rows.map((r) => r.applicationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getComposeIdsInOrg(
|
||||||
|
orgId: string,
|
||||||
|
accessedServices: string[] | null,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ composeId: compose.composeId })
|
||||||
|
.from(compose)
|
||||||
|
.innerJoin(
|
||||||
|
environments,
|
||||||
|
eq(compose.environmentId, environments.environmentId),
|
||||||
|
)
|
||||||
|
.innerJoin(projects, eq(environments.projectId, projects.projectId))
|
||||||
|
.where(
|
||||||
|
accessedServices !== null
|
||||||
|
? and(
|
||||||
|
eq(projects.organizationId, orgId),
|
||||||
|
inArray(compose.composeId, accessedServices),
|
||||||
|
)
|
||||||
|
: eq(projects.organizationId, orgId),
|
||||||
|
);
|
||||||
|
return rows.map((r) => r.composeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All deployments for applications and compose in the org.
|
||||||
|
* Pass accessedServices for members (only those services), null for owner/admin.
|
||||||
|
*/
|
||||||
|
export const findAllDeploymentsCentralized = async (
|
||||||
|
orgId: string,
|
||||||
|
accessedServices: string[] | null,
|
||||||
|
) => {
|
||||||
|
if (accessedServices !== null && accessedServices.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [appIds, compIds] = await Promise.all([
|
||||||
|
getApplicationIdsInOrg(orgId, accessedServices),
|
||||||
|
getComposeIdsInOrg(orgId, accessedServices),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (appIds.length === 0 && compIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditions = [
|
||||||
|
...(appIds.length > 0 ? [inArray(deployments.applicationId, appIds)] : []),
|
||||||
|
...(compIds.length > 0 ? [inArray(deployments.composeId, compIds)] : []),
|
||||||
|
];
|
||||||
|
const whereClause =
|
||||||
|
conditions.length === 0
|
||||||
|
? sql`1 = 0`
|
||||||
|
: conditions.length === 1
|
||||||
|
? conditions[0]
|
||||||
|
: or(...conditions);
|
||||||
|
|
||||||
|
return db.query.deployments.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
orderBy: desc(deployments.createdAt),
|
||||||
|
with: centralizedDeploymentsWith,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const updateDeployment = async (
|
export const updateDeployment = async (
|
||||||
deploymentId: string,
|
deploymentId: string,
|
||||||
deploymentData: Partial<Deployment>,
|
deploymentData: Partial<Deployment>,
|
||||||
|
|||||||
@@ -34,42 +34,139 @@ export const createEnvironment = async (
|
|||||||
export const findEnvironmentById = async (environmentId: string) => {
|
export const findEnvironmentById = async (environmentId: string) => {
|
||||||
const environment = await db.query.environments.findFirst({
|
const environment = await db.query.environments.findFirst({
|
||||||
where: eq(environments.environmentId, environmentId),
|
where: eq(environments.environmentId, environmentId),
|
||||||
|
columns: {
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
environmentId: true,
|
||||||
|
isDefault: true,
|
||||||
|
projectId: true,
|
||||||
|
env: true,
|
||||||
|
},
|
||||||
with: {
|
with: {
|
||||||
applications: {
|
applications: {
|
||||||
with: {
|
with: {
|
||||||
deployments: true,
|
server: {
|
||||||
server: true,
|
columns: {
|
||||||
|
name: true,
|
||||||
|
serverId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
name: true,
|
||||||
|
applicationId: true,
|
||||||
|
createdAt: true,
|
||||||
|
applicationStatus: true,
|
||||||
|
description: true,
|
||||||
|
serverId: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mariadb: {
|
mariadb: {
|
||||||
with: {
|
with: {
|
||||||
server: true,
|
server: {
|
||||||
|
columns: {
|
||||||
|
name: true,
|
||||||
|
serverId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
mariadbId: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
applicationStatus: true,
|
||||||
|
description: true,
|
||||||
|
serverId: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mongo: {
|
mongo: {
|
||||||
with: {
|
with: {
|
||||||
server: true,
|
server: {
|
||||||
|
columns: {
|
||||||
|
name: true,
|
||||||
|
serverId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
mongoId: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
applicationStatus: true,
|
||||||
|
description: true,
|
||||||
|
serverId: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mysql: {
|
mysql: {
|
||||||
with: {
|
with: {
|
||||||
server: true,
|
server: {
|
||||||
|
columns: {
|
||||||
|
name: true,
|
||||||
|
serverId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
mysqlId: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
applicationStatus: true,
|
||||||
|
description: true,
|
||||||
|
serverId: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
postgres: {
|
postgres: {
|
||||||
with: {
|
with: {
|
||||||
server: true,
|
server: {
|
||||||
|
columns: {
|
||||||
|
name: true,
|
||||||
|
serverId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
postgresId: true,
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
createdAt: true,
|
||||||
|
applicationStatus: true,
|
||||||
|
serverId: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
redis: {
|
redis: {
|
||||||
with: {
|
with: {
|
||||||
server: true,
|
server: {
|
||||||
|
columns: {
|
||||||
|
name: true,
|
||||||
|
serverId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
redisId: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
applicationStatus: true,
|
||||||
|
description: true,
|
||||||
|
serverId: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
compose: {
|
compose: {
|
||||||
with: {
|
with: {
|
||||||
deployments: true,
|
server: {
|
||||||
server: true,
|
columns: {
|
||||||
|
name: true,
|
||||||
|
serverId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
composeId: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
composeStatus: true,
|
||||||
|
description: true,
|
||||||
|
serverId: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
project: true,
|
project: true,
|
||||||
@@ -98,6 +195,12 @@ export const findEnvironmentsByProjectId = async (projectId: string) => {
|
|||||||
compose: true,
|
compose: true,
|
||||||
project: true,
|
project: true,
|
||||||
},
|
},
|
||||||
|
columns: {
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
environmentId: true,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return projectEnvironments;
|
return projectEnvironments;
|
||||||
};
|
};
|
||||||
@@ -169,6 +272,7 @@ export const duplicateEnvironment = async (
|
|||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description || originalEnvironment.description,
|
description: input.description || originalEnvironment.description,
|
||||||
projectId: originalEnvironment.projectId,
|
projectId: originalEnvironment.projectId,
|
||||||
|
env: originalEnvironment.env,
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
.then((value) => value[0]);
|
.then((value) => value[0]);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function shEscape(s: string | undefined): string {
|
|||||||
return `'${s.replace(/'/g, `'\\''`)}'`;
|
return `'${s.replace(/'/g, `'\\''`)}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeDockerLoginCommand(
|
export function safeDockerLoginCommand(
|
||||||
registry: string | undefined,
|
registry: string | undefined,
|
||||||
user: string | undefined,
|
user: string | undefined,
|
||||||
pass: string | undefined,
|
pass: string | undefined,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { findDeploymentById } from "./deployment";
|
|||||||
import type { Mount } from "./mount";
|
import type { Mount } from "./mount";
|
||||||
import type { Port } from "./port";
|
import type { Port } from "./port";
|
||||||
import type { Project } from "./project";
|
import type { Project } from "./project";
|
||||||
import type { Registry } from "./registry";
|
import { type Registry, safeDockerLoginCommand } from "./registry";
|
||||||
|
|
||||||
export const createRollback = async (
|
export const createRollback = async (
|
||||||
input: z.infer<typeof createRollbackSchema>,
|
input: z.infer<typeof createRollbackSchema>,
|
||||||
@@ -111,7 +111,7 @@ const deleteRollbackImage = async (image: string, serverId?: string | null) => {
|
|||||||
const command = `docker image rm ${image} --force`;
|
const command = `docker image rm ${image} --force`;
|
||||||
|
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
await execAsyncRemote(command, serverId);
|
await execAsyncRemote(serverId, command);
|
||||||
} else {
|
} else {
|
||||||
await execAsync(command);
|
await execAsync(command);
|
||||||
}
|
}
|
||||||
@@ -171,6 +171,23 @@ export const rollback = async (rollbackId: string) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dockerLoginForRegistry = async (
|
||||||
|
registry: Registry,
|
||||||
|
serverId?: string | null,
|
||||||
|
) => {
|
||||||
|
const loginCommand = safeDockerLoginCommand(
|
||||||
|
registry.registryUrl,
|
||||||
|
registry.username,
|
||||||
|
registry.password,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
await execAsyncRemote(serverId, loginCommand);
|
||||||
|
} else {
|
||||||
|
await execAsync(loginCommand);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const rollbackApplication = async (
|
const rollbackApplication = async (
|
||||||
appName: string,
|
appName: string,
|
||||||
image: string,
|
image: string,
|
||||||
@@ -188,6 +205,14 @@ const rollbackApplication = async (
|
|||||||
throw new Error("Full context is required for rollback");
|
throw new Error("Full context is required for rollback");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure Docker daemon is authenticated with the rollback registry
|
||||||
|
// before updating the swarm service. The authconfig in CreateServiceOptions
|
||||||
|
// alone is not sufficient — Docker Swarm also relies on the daemon's
|
||||||
|
// cached credentials (~/.docker/config.json) to distribute auth to nodes.
|
||||||
|
if (fullContext.rollbackRegistry) {
|
||||||
|
await dockerLoginForRegistry(fullContext.rollbackRegistry, serverId);
|
||||||
|
}
|
||||||
|
|
||||||
const docker = await getRemoteDocker(serverId);
|
const docker = await getRemoteDocker(serverId);
|
||||||
|
|
||||||
// Use the same configuration as mechanizeDockerContainer
|
// Use the same configuration as mechanizeDockerContainer
|
||||||
|
|||||||
@@ -413,17 +413,38 @@ export const checkPortInUse = async (
|
|||||||
serverId?: string,
|
serverId?: string,
|
||||||
): Promise<{ isInUse: boolean; conflictingContainer?: string }> => {
|
): Promise<{ isInUse: boolean; conflictingContainer?: string }> => {
|
||||||
try {
|
try {
|
||||||
const command = `docker ps -a --format '{{.Names}}' | grep -v '^dokploy-traefik$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`;
|
// Check if port is in use by a Docker container
|
||||||
const { stdout } = serverId
|
const dockerCommand = `docker ps -a --format '{{.Names}}' | grep -v '^dokploy-traefik$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`;
|
||||||
? await execAsyncRemote(serverId, command)
|
const { stdout: dockerOut } = serverId
|
||||||
: await execAsync(command);
|
? await execAsyncRemote(serverId, dockerCommand)
|
||||||
|
: await execAsync(dockerCommand);
|
||||||
|
|
||||||
const container = stdout.trim();
|
const container = dockerOut.trim();
|
||||||
|
|
||||||
return {
|
if (container) {
|
||||||
isInUse: !!container,
|
return {
|
||||||
conflictingContainer: container || undefined,
|
isInUse: true,
|
||||||
};
|
conflictingContainer: `container "${container}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if port is in use by a host-level service (non-Docker)
|
||||||
|
// Dokploy runs inside a container, so we spawn an ephemeral container
|
||||||
|
// with --net=host to share the host's network stack and use nc -z to
|
||||||
|
// check if something is listening on the port
|
||||||
|
const hostCommand = `docker run --rm --net=host busybox sh -c 'nc -z 0.0.0.0 ${port} 2>/dev/null && echo in_use || echo free'`;
|
||||||
|
const { stdout: hostOut } = serverId
|
||||||
|
? await execAsyncRemote(serverId, hostCommand)
|
||||||
|
: await execAsync(hostCommand);
|
||||||
|
|
||||||
|
if (hostOut.includes("in_use")) {
|
||||||
|
return {
|
||||||
|
isInUse: true,
|
||||||
|
conflictingContainer: "a host-level service",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isInUse: false };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error checking port availability:", error);
|
console.error("Error checking port availability:", error);
|
||||||
return { isInUse: false };
|
return { isInUse: false };
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
|
|||||||
baseURL: config.apiUrl,
|
baseURL: config.apiUrl,
|
||||||
});
|
});
|
||||||
case "azure":
|
case "azure":
|
||||||
|
// Azure OpenAI-compatible endpoints already include /v1 in the path.
|
||||||
|
// Using createAzure with such URLs causes a doubled /v1//v1/ suffix.
|
||||||
|
if (config.apiUrl.includes("/v1")) {
|
||||||
|
return createOpenAICompatible({
|
||||||
|
name: "azure",
|
||||||
|
baseURL: config.apiUrl,
|
||||||
|
headers: {
|
||||||
|
"api-key": config.apiKey,
|
||||||
|
Authorization: `Bearer ${config.apiKey}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
return createAzure({
|
return createAzure({
|
||||||
apiKey: config.apiKey,
|
apiKey: config.apiKey,
|
||||||
baseURL: config.apiUrl,
|
baseURL: config.apiUrl,
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ export const runComposeBackup = async (
|
|||||||
compose: Compose,
|
compose: Compose,
|
||||||
backup: BackupSchedule,
|
backup: BackupSchedule,
|
||||||
) => {
|
) => {
|
||||||
const { environmentId, name } = compose;
|
const { environmentId, name, appName } = compose;
|
||||||
const environment = await findEnvironmentById(environmentId);
|
const environment = await findEnvironmentById(environmentId);
|
||||||
const project = await findProjectById(environment.projectId);
|
const project = await findProjectById(environment.projectId);
|
||||||
const { prefix, databaseType } = backup;
|
const { prefix, databaseType, serviceName } = backup;
|
||||||
const destination = backup.destination;
|
const destination = backup.destination;
|
||||||
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
||||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
const s3AppName = serviceName ? `${appName}_${serviceName}` : appName;
|
||||||
|
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||||
const deployment = await createDeploymentBackup({
|
const deployment = await createDeploymentBackup({
|
||||||
backupId: backup.backupId,
|
backupId: backup.backupId,
|
||||||
title: "Compose Backup",
|
title: "Compose Backup",
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import path from "node:path";
|
|
||||||
import { CLEANUP_CRON_JOB } from "@dokploy/server/constants";
|
import { CLEANUP_CRON_JOB } from "@dokploy/server/constants";
|
||||||
import { member } from "@dokploy/server/db/schema";
|
import { member } from "@dokploy/server/db/schema";
|
||||||
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
||||||
@@ -11,7 +10,7 @@ import { startLogCleanup } from "../access-log/handler";
|
|||||||
import { cleanupAll } from "../docker/utils";
|
import { cleanupAll } from "../docker/utils";
|
||||||
import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup";
|
import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup";
|
||||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||||
import { getS3Credentials, scheduleBackup } from "./utils";
|
import { getS3Credentials, normalizeS3Path, scheduleBackup } from "./utils";
|
||||||
|
|
||||||
export const initCronJobs = async () => {
|
export const initCronJobs = async () => {
|
||||||
console.log("Setting up cron jobs....");
|
console.log("Setting up cron jobs....");
|
||||||
@@ -107,6 +106,20 @@ export const initCronJobs = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getServiceAppName = (backup: BackupSchedule): string => {
|
||||||
|
if (backup.compose?.appName) {
|
||||||
|
return backup.serviceName
|
||||||
|
? `${backup.compose.appName}_${backup.serviceName}`
|
||||||
|
: backup.compose.appName;
|
||||||
|
}
|
||||||
|
const serviceAppName =
|
||||||
|
backup.postgres?.appName ||
|
||||||
|
backup.mysql?.appName ||
|
||||||
|
backup.mariadb?.appName ||
|
||||||
|
backup.mongo?.appName;
|
||||||
|
return serviceAppName || backup.appName;
|
||||||
|
};
|
||||||
|
|
||||||
export const keepLatestNBackups = async (
|
export const keepLatestNBackups = async (
|
||||||
backup: BackupSchedule,
|
backup: BackupSchedule,
|
||||||
serverId?: string | null,
|
serverId?: string | null,
|
||||||
@@ -117,18 +130,16 @@ export const keepLatestNBackups = async (
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const rcloneFlags = getS3Credentials(backup.destination);
|
const rcloneFlags = getS3Credentials(backup.destination);
|
||||||
const backupFilesPath = path.join(
|
const appName = getServiceAppName(backup);
|
||||||
`:s3:${backup.destination.bucket}`,
|
const backupFilesPath = `:s3:${backup.destination.bucket}/${appName}/${normalizeS3Path(backup.prefix)}`;
|
||||||
backup.prefix,
|
|
||||||
);
|
|
||||||
|
|
||||||
// --include "*.sql.gz" or "*.zip" ensures nothing else other than the dokploy backup files are touched by rclone
|
// --include "*.sql.gz" or "*.zip" ensures nothing else other than the dokploy backup files are touched by rclone
|
||||||
const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*${backup.databaseType === "web-server" ? ".zip" : ".sql.gz"}" ${backupFilesPath}`;
|
const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*${backup.databaseType === "web-server" ? ".zip" : ".sql.gz"}" ${backupFilesPath}`;
|
||||||
// when we pipe the above command with this one, we only get the list of files we want to delete
|
// when we pipe the above command with this one, we only get the list of files we want to delete
|
||||||
const sortAndPickUnwantedBackups = `sort -r | tail -n +$((${backup.keepLatestCount}+1)) | xargs -I{}`;
|
const sortAndPickUnwantedBackups = `sort -r | tail -n +$((${backup.keepLatestCount}+1)) | xargs -I{}`;
|
||||||
// this command deletes the files
|
// this command deletes the files
|
||||||
// to test the deletion before actually deleting we can add --dry-run before ${backupFilesPath}/{}
|
// to test the deletion before actually deleting we can add --dry-run before ${backupFilesPath}{}
|
||||||
const rcloneDelete = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}/{}`;
|
const rcloneDelete = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}{}`;
|
||||||
|
|
||||||
const rcloneCommand = `${rcloneList} | ${sortAndPickUnwantedBackups} ${rcloneDelete}`;
|
const rcloneCommand = `${rcloneList} | ${sortAndPickUnwantedBackups} ${rcloneDelete}`;
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ export const runMariadbBackup = async (
|
|||||||
mariadb: Mariadb,
|
mariadb: Mariadb,
|
||||||
backup: BackupSchedule,
|
backup: BackupSchedule,
|
||||||
) => {
|
) => {
|
||||||
const { environmentId, name } = mariadb;
|
const { environmentId, name, appName } = mariadb;
|
||||||
const environment = await findEnvironmentById(environmentId);
|
const environment = await findEnvironmentById(environmentId);
|
||||||
const project = await findProjectById(environment.projectId);
|
const project = await findProjectById(environment.projectId);
|
||||||
const { prefix } = backup;
|
const { prefix } = backup;
|
||||||
const destination = backup.destination;
|
const destination = backup.destination;
|
||||||
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
||||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||||
const deployment = await createDeploymentBackup({
|
const deployment = await createDeploymentBackup({
|
||||||
backupId: backup.backupId,
|
backupId: backup.backupId,
|
||||||
title: "MariaDB Backup",
|
title: "MariaDB Backup",
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ import { execAsync, execAsyncRemote } from "../process/execAsync";
|
|||||||
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
|
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
|
||||||
|
|
||||||
export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
|
export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
|
||||||
const { environmentId, name } = mongo;
|
const { environmentId, name, appName } = mongo;
|
||||||
const environment = await findEnvironmentById(environmentId);
|
const environment = await findEnvironmentById(environmentId);
|
||||||
const project = await findProjectById(environment.projectId);
|
const project = await findProjectById(environment.projectId);
|
||||||
const { prefix } = backup;
|
const { prefix } = backup;
|
||||||
const destination = backup.destination;
|
const destination = backup.destination;
|
||||||
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
||||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||||
const deployment = await createDeploymentBackup({
|
const deployment = await createDeploymentBackup({
|
||||||
backupId: backup.backupId,
|
backupId: backup.backupId,
|
||||||
title: "MongoDB Backup",
|
title: "MongoDB Backup",
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ import { execAsync, execAsyncRemote } from "../process/execAsync";
|
|||||||
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
|
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
|
||||||
|
|
||||||
export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
|
export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
|
||||||
const { environmentId, name } = mysql;
|
const { environmentId, name, appName } = mysql;
|
||||||
const environment = await findEnvironmentById(environmentId);
|
const environment = await findEnvironmentById(environmentId);
|
||||||
const project = await findProjectById(environment.projectId);
|
const project = await findProjectById(environment.projectId);
|
||||||
const { prefix } = backup;
|
const { prefix } = backup;
|
||||||
const destination = backup.destination;
|
const destination = backup.destination;
|
||||||
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
||||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||||
const deployment = await createDeploymentBackup({
|
const deployment = await createDeploymentBackup({
|
||||||
backupId: backup.backupId,
|
backupId: backup.backupId,
|
||||||
title: "MySQL Backup",
|
title: "MySQL Backup",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const runPostgresBackup = async (
|
|||||||
postgres: Postgres,
|
postgres: Postgres,
|
||||||
backup: BackupSchedule,
|
backup: BackupSchedule,
|
||||||
) => {
|
) => {
|
||||||
const { name, environmentId } = postgres;
|
const { name, environmentId, appName } = postgres;
|
||||||
const environment = await findEnvironmentById(environmentId);
|
const environment = await findEnvironmentById(environmentId);
|
||||||
const project = await findProjectById(environment.projectId);
|
const project = await findProjectById(environment.projectId);
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export const runPostgresBackup = async (
|
|||||||
const { prefix } = backup;
|
const { prefix } = backup;
|
||||||
const destination = backup.destination;
|
const destination = backup.destination;
|
||||||
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
||||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||||
try {
|
try {
|
||||||
const rcloneFlags = getS3Credentials(destination);
|
const rcloneFlags = getS3Credentials(destination);
|
||||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
|
|||||||
const { BASE_PATH } = paths();
|
const { BASE_PATH } = paths();
|
||||||
const tempDir = await mkdtemp(join(tmpdir(), "dokploy-backup-"));
|
const tempDir = await mkdtemp(join(tmpdir(), "dokploy-backup-"));
|
||||||
const backupFileName = `webserver-backup-${timestamp}.zip`;
|
const backupFileName = `webserver-backup-${timestamp}.zip`;
|
||||||
const s3Path = `:s3:${destination.bucket}/${normalizeS3Path(backup.prefix)}${backupFileName}`;
|
const s3Path = `:s3:${destination.bucket}/${backup.appName}/${normalizeS3Path(backup.prefix)}${backupFileName}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await execAsync(`mkdir -p ${tempDir}/filesystem`);
|
await execAsync(`mkdir -p ${tempDir}/filesystem`);
|
||||||
@@ -67,7 +67,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
|
|||||||
await execAsync(cleanupCommand);
|
await execAsync(cleanupCommand);
|
||||||
|
|
||||||
await execAsync(
|
await execAsync(
|
||||||
`rsync -a --ignore-errors ${BASE_PATH}/ ${tempDir}/filesystem/`,
|
`rsync -a --ignore-errors --no-specials --no-devices ${BASE_PATH}/ ${tempDir}/filesystem/`,
|
||||||
);
|
);
|
||||||
|
|
||||||
writeStream.write("Copied filesystem to temp directory\n");
|
writeStream.write("Copied filesystem to temp directory\n");
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ Compose Type: ${composeType} ✅`;
|
|||||||
|
|
||||||
cd "${projectPath}";
|
cd "${projectPath}";
|
||||||
|
|
||||||
${compose.isolatedDeployment ? `docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create --attachable ${compose.appName}` : ""}
|
${compose.isolatedDeployment ? `docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create ${compose.composeType === "stack" ? "--driver overlay" : ""} --attachable ${compose.appName}` : ""}
|
||||||
env -i PATH="$PATH" ${exportEnvCommand} docker ${command.split(" ").join(" ")} 2>&1 || { echo "Error: ❌ Docker command failed"; exit 1; }
|
env -i PATH="$PATH" ${exportEnvCommand} docker ${command.split(" ").join(" ")} 2>&1 || { echo "Error: ❌ Docker command failed"; exit 1; }
|
||||||
${compose.isolatedDeployment ? `docker network connect ${compose.appName} $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1` : ""}
|
${compose.isolatedDeployment ? `docker network connect ${compose.appName} $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1` : ""}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ export const randomizeComposeFile = async (
|
|||||||
) => {
|
) => {
|
||||||
const compose = await findComposeById(composeId);
|
const compose = await findComposeById(composeId);
|
||||||
const composeFile = compose.composeFile;
|
const composeFile = compose.composeFile;
|
||||||
const composeData = parse(composeFile) as ComposeSpecification;
|
const composeData = parse(composeFile, {
|
||||||
|
maxAliasCount: 10000,
|
||||||
|
}) as ComposeSpecification;
|
||||||
|
|
||||||
const randomSuffix = suffix || generateRandomHash();
|
const randomSuffix = suffix || generateRandomHash();
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ export const loadDockerCompose = async (
|
|||||||
|
|
||||||
if (existsSync(path)) {
|
if (existsSync(path)) {
|
||||||
const yamlStr = readFileSync(path, "utf8");
|
const yamlStr = readFileSync(path, "utf8");
|
||||||
const parsedConfig = parse(yamlStr) as ComposeSpecification;
|
const parsedConfig = parse(yamlStr, {
|
||||||
|
maxAliasCount: 10000,
|
||||||
|
}) as ComposeSpecification;
|
||||||
return parsedConfig;
|
return parsedConfig;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -86,7 +88,9 @@ export const loadDockerComposeRemote = async (
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!stdout) return null;
|
if (!stdout) return null;
|
||||||
const parsedConfig = parse(stdout) as ComposeSpecification;
|
const parsedConfig = parse(stdout, {
|
||||||
|
maxAliasCount: 10000,
|
||||||
|
}) as ComposeSpecification;
|
||||||
return parsedConfig;
|
return parsedConfig;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -106,10 +110,6 @@ export const writeDomainsToCompose = async (
|
|||||||
compose: Compose,
|
compose: Compose,
|
||||||
domains: Domain[],
|
domains: Domain[],
|
||||||
) => {
|
) => {
|
||||||
if (!domains.length) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const composeConverted = await addDomainToCompose(compose, domains);
|
const composeConverted = await addDomainToCompose(compose, domains);
|
||||||
const path = getComposePath(compose);
|
const path = getComposePath(compose);
|
||||||
@@ -145,7 +145,7 @@ export const addDomainToCompose = async (
|
|||||||
result = await loadDockerCompose(compose);
|
result = await loadDockerCompose(compose);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result || domains.length === 0) {
|
if (!result) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user