mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-17 13:15:23 +02:00
Compare commits
53 Commits
v0.28.0
...
dosu/doc-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c5d49ca66 | ||
|
|
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 |
@@ -62,6 +62,16 @@ pnpm install
|
||||
cp apps/dokploy/.env.example apps/dokploy/.env
|
||||
```
|
||||
|
||||
### Optional Docker Configuration
|
||||
|
||||
The following environment variables can be added to your `.env` file if you need custom Docker daemon configuration:
|
||||
|
||||
- **DOCKER_API_VERSION**: Specify which Docker API version to use (optional)
|
||||
- **DOCKER_HOST**: Specify a custom Docker daemon host (optional)
|
||||
- **DOCKER_PORT**: Specify a custom Docker daemon port (optional)
|
||||
|
||||
These variables are typically not needed for standard local development but can be useful if you need to connect to a remote Docker daemon or require a specific Docker API version.
|
||||
|
||||
## Requirements
|
||||
|
||||
- [Docker](/GUIDES.md#docker)
|
||||
@@ -171,6 +181,11 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.
|
||||
- **Issue Association:** For any significant change, it's highly recommended to open an issue first to discuss the proposed solution with the community and maintainers. This ensures alignment and avoids duplicated effort. If your PR resolves an existing issue, please link it in the description (e.g., `Fixes #123`, `Closes #456`).
|
||||
- **Large Features:** Pull Requests that introduce very large or broad features **will not be accepted** unless the idea is first outlined and discussed in a GitHub issue. Large features should be designed together with the Dokploy team so the project stays coherent and moves in the same direction. Open an issue to propose and align on the design before implementing.
|
||||
|
||||
### Pull Request Guidelines
|
||||
|
||||
- **Keep PRs small and focused.** Avoid very large PRs; prefer several smaller PRs (e.g., one template or one logical change per PR). This speeds up review and keeps the history clear.
|
||||
- **Test before submitting.** Any PR that has not been tested by the contributor will be closed. This keeps the PR queue tidy and ensures that only contributions that have been verified by their authors are considered.
|
||||
|
||||
Thank you for your contribution!
|
||||
|
||||
## Templates
|
||||
|
||||
@@ -1,2 +1,11 @@
|
||||
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
|
||||
@@ -6,3 +6,249 @@ npm run dev
|
||||
```
|
||||
open http://localhost:3000
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The API server requires the following environment variables for configuration:
|
||||
|
||||
### Inngest Configuration
|
||||
|
||||
Required for the GET /jobs endpoint to list deployment jobs:
|
||||
|
||||
- **INNGEST_BASE_URL** - The base URL for the Inngest instance
|
||||
- Self-hosted: `http://localhost:8288`
|
||||
- Production: `https://dev-inngest.dokploy.com`
|
||||
|
||||
- **INNGEST_SIGNING_KEY** - The signing key for authenticating with Inngest
|
||||
|
||||
Optional configuration for filtering and pagination:
|
||||
|
||||
- **INNGEST_EVENTS_RECEIVED_AFTER** (optional) - An RFC3339 timestamp to filter events received after a specific date (e.g., `2024-01-01T00:00:00Z`). If unset, no date filter is applied.
|
||||
|
||||
- **INNGEST_JOBS_MAX_EVENTS** (optional) - Maximum number of events to fetch when listing jobs. Default is 100, maximum is 10000. Used for pagination with cursor.
|
||||
|
||||
### Lemon Squeezy Integration
|
||||
|
||||
- **LEMON_SQUEEZY_API_KEY** - API key for Lemon Squeezy integration
|
||||
- **LEMON_SQUEEZY_STORE_ID** - Store ID for Lemon Squeezy integration
|
||||
|
||||
### Docker Configuration
|
||||
|
||||
Optional configuration for customizing Docker daemon connections:
|
||||
|
||||
- **DOCKER_API_VERSION** (optional) - Specifies which Docker API version to use when connecting to the Docker daemon. If not set, the Docker client uses the default API version.
|
||||
|
||||
- **DOCKER_HOST** (optional) - Specifies the Docker daemon host to connect to. If not set, uses the default Docker socket connection.
|
||||
|
||||
- **DOCKER_PORT** (optional) - Specifies the port for connecting to the Docker daemon. If not set, uses the default port.
|
||||
|
||||
These variables allow advanced users to customize how the Dokploy API server connects to Docker, which can be useful for connecting to remote Docker daemons or using specific API versions.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### GET /jobs
|
||||
|
||||
Lists deployment jobs (Inngest runs) for a specified server.
|
||||
|
||||
**Query Parameters:**
|
||||
- `serverId` (required) - The ID of the server to list deployment jobs for
|
||||
|
||||
**Response:**
|
||||
Returns an array of deployment job objects with the same shape as BullMQ queue jobs:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "string",
|
||||
"name": "string",
|
||||
"data": {},
|
||||
"timestamp": 0,
|
||||
"processedOn": 0,
|
||||
"finishedOn": 0,
|
||||
"failedReason": "string",
|
||||
"state": "string"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `400` - serverId is not provided
|
||||
- `503` - INNGEST_BASE_URL is not configured
|
||||
- `200` - Empty array on other errors
|
||||
|
||||
This endpoint is used by the UI to display deployment queue information in the dashboard.
|
||||
|
||||
## Search Endpoints
|
||||
|
||||
The following search endpoints provide flexible querying capabilities with pagination support. All search endpoints respect member permissions, returning only resources the user has access to.
|
||||
|
||||
### application.search
|
||||
|
||||
Search applications across name, appName, description, repository, owner, and dockerImage fields.
|
||||
|
||||
**Query Parameters:**
|
||||
- `q` (optional string) - General search term that searches across name, appName, description, repository, owner, and dockerImage
|
||||
- `name` (optional string) - Filter by application name
|
||||
- `appName` (optional string) - Filter by app name
|
||||
- `description` (optional string) - Filter by description
|
||||
- `repository` (optional string) - Filter by repository
|
||||
- `owner` (optional string) - Filter by owner
|
||||
- `dockerImage` (optional string) - Filter by Docker image
|
||||
- `projectId` (optional string) - Filter by project ID
|
||||
- `environmentId` (optional string) - Filter by environment ID
|
||||
- `limit` (number, default 20, min 1, max 100) - Maximum number of results
|
||||
- `offset` (number, default 0, min 0) - Pagination offset
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"applicationId": "string",
|
||||
"name": "string",
|
||||
"appName": "string",
|
||||
"description": "string",
|
||||
"environmentId": "string",
|
||||
"applicationStatus": "string",
|
||||
"sourceType": "string",
|
||||
"createdAt": "string"
|
||||
}
|
||||
],
|
||||
"total": 0
|
||||
}
|
||||
```
|
||||
|
||||
### compose.search
|
||||
|
||||
Search compose services with filtering by name, appName, and description.
|
||||
|
||||
**Query Parameters:**
|
||||
- `q` (optional string) - General search term across name, appName, description
|
||||
- `name` (optional string) - Filter by name
|
||||
- `appName` (optional string) - Filter by app name
|
||||
- `description` (optional string) - Filter by description
|
||||
- `projectId` (optional string) - Filter by project ID
|
||||
- `environmentId` (optional string) - Filter by environment ID
|
||||
- `limit` (number, default 20, min 1, max 100) - Maximum results
|
||||
- `offset` (number, default 0, min 0) - Pagination offset
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"composeId": "string",
|
||||
"name": "string",
|
||||
"appName": "string",
|
||||
"description": "string",
|
||||
"environmentId": "string",
|
||||
"composeStatus": "string",
|
||||
"sourceType": "string",
|
||||
"createdAt": "string"
|
||||
}
|
||||
],
|
||||
"total": 0
|
||||
}
|
||||
```
|
||||
|
||||
### environment.search
|
||||
|
||||
Search environments by name and description.
|
||||
|
||||
**Query Parameters:**
|
||||
- `q` (optional string) - General search term across name and description
|
||||
- `name` (optional string) - Filter by name
|
||||
- `description` (optional string) - Filter by description
|
||||
- `projectId` (optional string) - Filter by project ID
|
||||
- `limit` (number, default 20, min 1, max 100) - Maximum results
|
||||
- `offset` (number, default 0, min 0) - Pagination offset
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"environmentId": "string",
|
||||
"name": "string",
|
||||
"description": "string",
|
||||
"createdAt": "string",
|
||||
"env": "string",
|
||||
"projectId": "string",
|
||||
"isDefault": true
|
||||
}
|
||||
],
|
||||
"total": 0
|
||||
}
|
||||
```
|
||||
|
||||
### project.search
|
||||
|
||||
Search projects by name and description.
|
||||
|
||||
**Query Parameters:**
|
||||
- `q` (optional string) - General search term across name and description
|
||||
- `name` (optional string) - Filter by name
|
||||
- `description` (optional string) - Filter by description
|
||||
- `limit` (number, default 20, min 1, max 100) - Maximum results
|
||||
- `offset` (number, default 0, min 0) - Pagination offset
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"projectId": "string",
|
||||
"name": "string",
|
||||
"description": "string",
|
||||
"createdAt": "string",
|
||||
"organizationId": "string",
|
||||
"env": "string"
|
||||
}
|
||||
],
|
||||
"total": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Database Service Search Endpoints
|
||||
|
||||
The following database services all share the same search interface:
|
||||
- **postgres.search**
|
||||
- **mysql.search**
|
||||
- **mariadb.search**
|
||||
- **mongo.search**
|
||||
- **redis.search**
|
||||
|
||||
**Query Parameters:**
|
||||
- `q` (optional string) - General search term across name, appName, description
|
||||
- `name` (optional string) - Filter by name
|
||||
- `appName` (optional string) - Filter by app name
|
||||
- `description` (optional string) - Filter by description
|
||||
- `projectId` (optional string) - Filter by project ID
|
||||
- `environmentId` (optional string) - Filter by environment ID
|
||||
- `limit` (number, default 20, min 1, max 100) - Maximum results
|
||||
- `offset` (number, default 0, min 0) - Pagination offset
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"postgresId": "string",
|
||||
"name": "string",
|
||||
"appName": "string",
|
||||
"description": "string",
|
||||
"environmentId": "string",
|
||||
"applicationStatus": "string",
|
||||
"createdAt": "string"
|
||||
}
|
||||
],
|
||||
"total": 0
|
||||
}
|
||||
```
|
||||
|
||||
*Note: The response shape is similar across all database services, with the ID field varying (e.g., `mysqlId`, `mariadbId`, `mongoId`, `redisId`).*
|
||||
|
||||
**Search Behavior:**
|
||||
- All searches use case-insensitive pattern matching with wildcards
|
||||
- Results are ordered by creation date (descending)
|
||||
- Members only see services they have access to
|
||||
- Returns total count for pagination UI
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type DeployJob,
|
||||
deployJobSchema,
|
||||
} from "./schema.js";
|
||||
import { fetchDeploymentJobs } from "./service.js";
|
||||
import { deploy } from "./utils.js";
|
||||
|
||||
const app = new Hono();
|
||||
@@ -118,7 +119,6 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => {
|
||||
200,
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("error", error);
|
||||
logger.error("Failed to send deployment event", error);
|
||||
return c.json(
|
||||
{
|
||||
@@ -176,6 +176,29 @@ app.get("/health", async (c) => {
|
||||
return c.json({ status: "ok" });
|
||||
});
|
||||
|
||||
// List deployment jobs (Inngest runs) for a server - same shape as BullMQ queue for the UI
|
||||
app.get("/jobs", async (c) => {
|
||||
const serverId = c.req.query("serverId");
|
||||
if (!serverId) {
|
||||
return c.json({ message: "serverId is required" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await fetchDeploymentJobs(serverId);
|
||||
return c.json(rows);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("INNGEST_BASE_URL")) {
|
||||
return c.json(
|
||||
{ message: "INNGEST_BASE_URL is required to list deployment jobs" },
|
||||
503,
|
||||
);
|
||||
}
|
||||
logger.error("Failed to fetch jobs from Inngest", { serverId, error });
|
||||
return c.json([], 200);
|
||||
}
|
||||
});
|
||||
|
||||
// Serve Inngest functions endpoint
|
||||
app.on(
|
||||
["GET", "POST", "PUT"],
|
||||
|
||||
239
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,
|
||||
updatePreviewDeployment,
|
||||
} from "@dokploy/server";
|
||||
import type { DeployJob } from "./schema";
|
||||
import type { DeployJob } from "./schema.js";
|
||||
|
||||
export const deploy = async (job: DeployJob) => {
|
||||
try {
|
||||
|
||||
@@ -14,13 +14,18 @@ vi.mock("@dokploy/server/db", () => {
|
||||
set: vi.fn(() => chain),
|
||||
where: vi.fn(() => chain),
|
||||
returning: vi.fn().mockResolvedValue([{}] as any),
|
||||
from: vi.fn(() => chain),
|
||||
innerJoin: vi.fn(() => chain),
|
||||
then: (resolve: (v: any) => void) => {
|
||||
resolve([]);
|
||||
},
|
||||
} as any;
|
||||
return chain;
|
||||
};
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
select: vi.fn(() => createChainableMock()),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(() => createChainableMock()),
|
||||
delete: vi.fn(),
|
||||
@@ -31,6 +36,9 @@ vi.mock("@dokploy/server/db", () => {
|
||||
patch: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
member: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,13 +15,18 @@ vi.mock("@dokploy/server/db", () => {
|
||||
set: vi.fn(() => chain),
|
||||
where: vi.fn(() => chain),
|
||||
returning: vi.fn().mockResolvedValue([{}]),
|
||||
from: vi.fn(() => chain),
|
||||
innerJoin: vi.fn(() => chain),
|
||||
then: (resolve: (v: any) => void) => {
|
||||
resolve([]);
|
||||
},
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
select: vi.fn(() => createChainableMock()),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(() => createChainableMock()),
|
||||
delete: vi.fn(),
|
||||
@@ -32,6 +37,9 @@ vi.mock("@dokploy/server/db", () => {
|
||||
patch: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
member: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,7 +12,11 @@ vi.mock("@dokploy/server/db", () => {
|
||||
chain.where = () => chain;
|
||||
chain.values = () => chain;
|
||||
chain.returning = () => Promise.resolve([{}]);
|
||||
chain.then = undefined;
|
||||
chain.from = () => chain;
|
||||
chain.innerJoin = () => chain;
|
||||
chain.then = (resolve: (value: unknown) => void) => {
|
||||
resolve([]);
|
||||
};
|
||||
|
||||
const tableMock = {
|
||||
findFirst: vi.fn(() => Promise.resolve(undefined)),
|
||||
@@ -21,7 +25,6 @@ vi.mock("@dokploy/server/db", () => {
|
||||
update: vi.fn(() => chain),
|
||||
delete: vi.fn(() => chain),
|
||||
};
|
||||
const createQueryMock = () => tableMock;
|
||||
|
||||
return {
|
||||
db: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -41,6 +41,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const UpdateMariadb = ({ mariadbId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, error, isError, isPending } =
|
||||
api.mariadb.update.useMutation();
|
||||
@@ -79,6 +80,7 @@ export const UpdateMariadb = ({ mariadbId }: Props) => {
|
||||
utils.mariadb.one.invalidate({
|
||||
mariadbId: mariadbId,
|
||||
});
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating the Mariadb");
|
||||
@@ -87,7 +89,7 @@ export const UpdateMariadb = ({ mariadbId }: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -41,6 +41,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const UpdateMysql = ({ mysqlId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, error, isError, isPending } =
|
||||
api.mysql.update.useMutation();
|
||||
@@ -79,6 +80,7 @@ export const UpdateMysql = ({ mysqlId }: Props) => {
|
||||
utils.mysql.one.invalidate({
|
||||
mysqlId: mysqlId,
|
||||
});
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating MySQL");
|
||||
@@ -87,7 +89,7 @@ export const UpdateMysql = ({ mysqlId }: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const organizationSchema = z.object({
|
||||
@@ -55,8 +54,6 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
const { mutateAsync, isPending } = organizationId
|
||||
? api.organization.update.useMutation()
|
||||
: api.organization.create.useMutation();
|
||||
const { refetch: refetchActiveOrganization } =
|
||||
authClient.useActiveOrganization();
|
||||
|
||||
const form = useForm<OrganizationFormValues>({
|
||||
resolver: zodResolver(organizationSchema),
|
||||
@@ -89,7 +86,7 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
utils.organization.all.invalidate();
|
||||
if (organizationId) {
|
||||
utils.organization.one.invalidate({ organizationId });
|
||||
refetchActiveOrganization();
|
||||
utils.organization.active.invalidate();
|
||||
}
|
||||
setOpen(false);
|
||||
})
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export type Services = {
|
||||
appName: string;
|
||||
serverId?: string | null;
|
||||
name: string;
|
||||
type:
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
AlertTriangle,
|
||||
ArrowUpDown,
|
||||
BookIcon,
|
||||
ExternalLinkIcon,
|
||||
FolderInput,
|
||||
Loader2,
|
||||
MoreHorizontalIcon,
|
||||
@@ -16,7 +15,6 @@ import { toast } from "sonner";
|
||||
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -40,10 +38,8 @@ import {
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
@@ -280,14 +276,6 @@ export const ShowProjects = () => {
|
||||
)
|
||||
.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
|
||||
const accessibleEnvironment =
|
||||
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">
|
||||
{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>
|
||||
<CardTitle className="flex items-center justify-between gap-2 overflow-clip">
|
||||
<span className="flex flex-col gap-1.5 ">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -41,6 +41,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const UpdateRedis = ({ redisId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, error, isError, isPending } =
|
||||
api.redis.update.useMutation();
|
||||
@@ -79,6 +80,7 @@ export const UpdateRedis = ({ redisId }: Props) => {
|
||||
utils.redis.one.invalidate({
|
||||
redisId: redisId,
|
||||
});
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Redis");
|
||||
@@ -87,7 +89,7 @@ export const UpdateRedis = ({ redisId }: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -6,6 +6,9 @@ import { Button } from "@/components/ui/button";
|
||||
import type { LogEntry } from "./show-requests";
|
||||
|
||||
export const getStatusColor = (status: number) => {
|
||||
if (status === 0) {
|
||||
return "secondary";
|
||||
}
|
||||
if (status >= 100 && status < 200) {
|
||||
return "outline";
|
||||
}
|
||||
@@ -21,6 +24,24 @@ export const getStatusColor = (status: number) => {
|
||||
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>[] = [
|
||||
{
|
||||
accessorKey: "level",
|
||||
@@ -59,10 +80,10 @@ export const columns: ColumnDef<LogEntry>[] = [
|
||||
</div>
|
||||
<div className="flex flex-row gap-3 w-full">
|
||||
<Badge variant={getStatusColor(log.OriginStatus)}>
|
||||
Status: {log.OriginStatus}
|
||||
Status: {formatStatusLabel(log.OriginStatus)}
|
||||
</Badge>
|
||||
<Badge variant={"secondary"}>
|
||||
Exec Time: {`${log.Duration / 1000000000}s`}
|
||||
Exec Time: {formatDuration(log.Duration)}
|
||||
</Badge>
|
||||
<Badge variant={"secondary"}>IP: {log.ClientAddr}</Badge>
|
||||
</div>
|
||||
|
||||
@@ -152,7 +152,15 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
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") {
|
||||
return <Badge variant="secondary">{value}</Badge>;
|
||||
@@ -161,7 +169,11 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
|
||||
return <Badge variant="outline">{value}</Badge>;
|
||||
}
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -174,6 +174,14 @@ export const SearchCommand = () => {
|
||||
>
|
||||
Projects
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
router.push("/dashboard/deployments");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Deployments
|
||||
</CommandItem>
|
||||
{!isCloud && (
|
||||
<>
|
||||
<CommandItem
|
||||
|
||||
@@ -17,7 +17,8 @@ import { api } from "@/utils/api";
|
||||
|
||||
export const AddGithubProvider = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
||||
const { data: activeOrganization } = api.organization.active.useQuery();
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data } = api.user.get.useQuery();
|
||||
const [manifest, setManifest] = useState("");
|
||||
@@ -30,7 +31,7 @@ export const AddGithubProvider = () => {
|
||||
const url = document.location.origin;
|
||||
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()}`,
|
||||
url: origin,
|
||||
hook_attributes: {
|
||||
@@ -52,7 +53,7 @@ export const AddGithubProvider = () => {
|
||||
);
|
||||
|
||||
setManifest(manifest);
|
||||
}, [data?.id]);
|
||||
}, [activeOrganization?.id, session?.user?.id]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
|
||||
@@ -100,7 +100,7 @@ export const SetupMonitoring = ({ serverId }: Props) => {
|
||||
|
||||
const url = useUrl();
|
||||
|
||||
const { data: projects } = api.project.all.useQuery();
|
||||
const { data: projects } = api.project.allForPermissions.useQuery();
|
||||
|
||||
const extractServicesFromProjects = () => {
|
||||
if (!projects) return [];
|
||||
|
||||
@@ -55,7 +55,7 @@ export const AddInvitation = () => {
|
||||
api.notification.getEmailProviders.useQuery();
|
||||
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
||||
const { data: activeOrganization } = api.organization.active.useQuery();
|
||||
|
||||
const form = useForm<AddInvitation>({
|
||||
defaultValues: {
|
||||
|
||||
@@ -28,8 +28,12 @@ import {
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api, type RouterOutputs } from "@/utils/api";
|
||||
|
||||
type Project = RouterOutputs["project"]["all"][number];
|
||||
type Environment = Project["environments"][number];
|
||||
/** Shape returned by project.allForPermissions (admin only). Used for the permissions UI. */
|
||||
type ProjectForPermissions =
|
||||
RouterOutputs["project"]["allForPermissions"][number];
|
||||
type EnvironmentForPermissions = ProjectForPermissions["environments"][number];
|
||||
|
||||
type Environment = EnvironmentForPermissions;
|
||||
|
||||
export type Services = {
|
||||
appName: string;
|
||||
@@ -173,7 +177,9 @@ interface Props {
|
||||
|
||||
export const AddUserPermissions = ({ userId }: Props) => {
|
||||
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(
|
||||
{
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type LucideIcon,
|
||||
Package,
|
||||
PieChart,
|
||||
Rocket,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Star,
|
||||
@@ -145,6 +146,12 @@ const MENU: Menu = {
|
||||
url: "/dashboard/projects",
|
||||
icon: Folder,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Deployments",
|
||||
url: "/dashboard/deployments",
|
||||
icon: Rocket,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Monitoring",
|
||||
@@ -550,8 +557,7 @@ function SidebarLogo() {
|
||||
const { mutateAsync: setDefaultOrganization, isPending: isSettingDefault } =
|
||||
api.organization.setDefault.useMutation();
|
||||
const { isMobile } = useSidebar();
|
||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
||||
const _utils = api.useUtils();
|
||||
const { data: activeOrganization } = api.organization.active.useQuery();
|
||||
|
||||
const { data: invitations, refetch: refetchInvitations } =
|
||||
api.user.getInvitations.useQuery();
|
||||
|
||||
@@ -32,7 +32,6 @@ interface Props {
|
||||
}
|
||||
|
||||
export const BreadcrumbSidebar = ({ list }: Props) => {
|
||||
console.log(list);
|
||||
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">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
|
||||
@@ -13,7 +13,7 @@ const Command = React.forwardRef<
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
@@ -44,7 +44,7 @@ const CommandInput = React.forwardRef<
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.28.0",
|
||||
"version": "v0.28.3",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -55,7 +55,7 @@
|
||||
"@codemirror/legacy-modes": "6.4.0",
|
||||
"@codemirror/view": "6.29.0",
|
||||
"@dokploy/server": "workspace:*",
|
||||
"@dokploy/trpc-openapi": "0.0.16",
|
||||
"@dokploy/trpc-openapi": "0.0.17",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@octokit/auth-app": "^6.1.3",
|
||||
|
||||
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,
|
||||
} from "next";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
import { type ReactElement, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import superjson from "superjson";
|
||||
@@ -100,7 +100,6 @@ import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export type Services = {
|
||||
appName: string;
|
||||
serverId?: string | null;
|
||||
serverName?: string | null;
|
||||
name: string;
|
||||
@@ -146,7 +145,6 @@ export const extractServicesFromEnvironment = (
|
||||
}
|
||||
}
|
||||
return {
|
||||
appName: item.appName,
|
||||
name: item.name,
|
||||
type: "application",
|
||||
id: item.applicationId,
|
||||
@@ -161,7 +159,6 @@ export const extractServicesFromEnvironment = (
|
||||
|
||||
const mariadb: Services[] =
|
||||
environment.mariadb?.map((item) => ({
|
||||
appName: item.appName,
|
||||
name: item.name,
|
||||
type: "mariadb",
|
||||
id: item.mariadbId,
|
||||
@@ -174,7 +171,6 @@ export const extractServicesFromEnvironment = (
|
||||
|
||||
const postgres: Services[] =
|
||||
environment.postgres?.map((item) => ({
|
||||
appName: item.appName,
|
||||
name: item.name,
|
||||
type: "postgres",
|
||||
id: item.postgresId,
|
||||
@@ -187,7 +183,6 @@ export const extractServicesFromEnvironment = (
|
||||
|
||||
const mongo: Services[] =
|
||||
environment.mongo?.map((item) => ({
|
||||
appName: item.appName,
|
||||
name: item.name,
|
||||
type: "mongo",
|
||||
id: item.mongoId,
|
||||
@@ -200,7 +195,6 @@ export const extractServicesFromEnvironment = (
|
||||
|
||||
const redis: Services[] =
|
||||
environment.redis?.map((item) => ({
|
||||
appName: item.appName,
|
||||
name: item.name,
|
||||
type: "redis",
|
||||
id: item.redisId,
|
||||
@@ -213,7 +207,6 @@ export const extractServicesFromEnvironment = (
|
||||
|
||||
const mysql: Services[] =
|
||||
environment.mysql?.map((item) => ({
|
||||
appName: item.appName,
|
||||
name: item.name,
|
||||
type: "mysql",
|
||||
id: item.mysqlId,
|
||||
@@ -242,7 +235,6 @@ export const extractServicesFromEnvironment = (
|
||||
}
|
||||
}
|
||||
return {
|
||||
appName: item.appName,
|
||||
name: item.name,
|
||||
type: "compose",
|
||||
id: item.composeId,
|
||||
@@ -366,7 +358,6 @@ const EnvironmentPage = (
|
||||
environmentId,
|
||||
});
|
||||
const { data: allProjects } = api.project.all.useQuery();
|
||||
const router = useRouter();
|
||||
|
||||
const [isMoveDialogOpen, setIsMoveDialogOpen] = useState(false);
|
||||
const [selectedTargetProject, setSelectedTargetProject] =
|
||||
@@ -420,6 +411,7 @@ const EnvironmentPage = (
|
||||
};
|
||||
|
||||
const handleServiceSelect = (serviceId: string, event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setSelectedServices((prev) =>
|
||||
prev.includes(serviceId)
|
||||
@@ -1471,101 +1463,99 @@ const EnvironmentPage = (
|
||||
<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">
|
||||
{filteredServices?.map((service) => (
|
||||
<Card
|
||||
<Link
|
||||
key={service.id}
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/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"
|
||||
href={`/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}`}
|
||||
className="block"
|
||||
>
|
||||
{service.serverId && (
|
||||
<div className="absolute -left-1 -top-2">
|
||||
<ServerIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
<Card className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border">
|
||||
{service.serverId && (
|
||||
<div className="absolute -left-1 -top-2">
|
||||
<ServerIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardFooter className="mt-auto">
|
||||
<div className="space-y-1 text-sm w-full">
|
||||
{service.serverName && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
|
||||
<ServerIcon className="size-3" />
|
||||
<span className="truncate">
|
||||
{service.serverName}
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
<DateTooltip date={service.createdAt}>
|
||||
Created
|
||||
</DateTooltip>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardFooter className="mt-auto">
|
||||
<div className="space-y-1 text-sm w-full">
|
||||
{service.serverName && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
|
||||
<ServerIcon className="size-3" />
|
||||
<span className="truncate">
|
||||
{service.serverName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<DateTooltip date={service.createdAt}>
|
||||
Created
|
||||
</DateTooltip>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
findApplicationById,
|
||||
findEnvironmentById,
|
||||
findGitProviderById,
|
||||
findMemberById,
|
||||
findProjectById,
|
||||
getApplicationStats,
|
||||
IS_CLOUD,
|
||||
@@ -32,7 +33,7 @@ import {
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
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 { z } from "zod";
|
||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||
@@ -53,6 +54,8 @@ import {
|
||||
apiSaveGitProvider,
|
||||
apiUpdateApplication,
|
||||
applications,
|
||||
environments,
|
||||
projects,
|
||||
} from "@/server/db/schema";
|
||||
import { deploymentWorker } from "@/server/queues/deployments-queue";
|
||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||
@@ -1002,4 +1005,138 @@ export const applicationRouter = createTRPCRouter({
|
||||
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,
|
||||
findEnvironmentById,
|
||||
findGitProviderById,
|
||||
findMemberById,
|
||||
findProjectById,
|
||||
findServerById,
|
||||
getComposeContainer,
|
||||
@@ -41,7 +42,7 @@ import {
|
||||
} from "@dokploy/server/templates/github";
|
||||
import { processTemplate } from "@dokploy/server/templates/processors";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
|
||||
import _ from "lodash";
|
||||
import { nanoid } from "nanoid";
|
||||
import { parse } from "toml";
|
||||
@@ -58,6 +59,8 @@ import {
|
||||
apiRedeployCompose,
|
||||
apiUpdateCompose,
|
||||
compose as composeTable,
|
||||
environments,
|
||||
projects,
|
||||
} from "@/server/db/schema";
|
||||
import { deploymentWorker } from "@/server/queues/deployments-queue";
|
||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||
@@ -1054,4 +1057,114 @@ export const composeRouter = createTRPCRouter({
|
||||
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,
|
||||
findAllDeploymentsByComposeId,
|
||||
findAllDeploymentsByServerId,
|
||||
findAllDeploymentsCentralized,
|
||||
findApplicationById,
|
||||
findComposeById,
|
||||
findDeploymentById,
|
||||
findMemberById,
|
||||
findServerById,
|
||||
IS_CLOUD,
|
||||
removeDeployment,
|
||||
resolveServicePath,
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
@@ -21,7 +25,10 @@ import {
|
||||
apiFindAllByServer,
|
||||
apiFindAllByType,
|
||||
deployments,
|
||||
server,
|
||||
} from "@/server/db/schema";
|
||||
import { myQueue } from "@/server/queues/queueSetup";
|
||||
import { fetchDeployApiJobs, type QueueJobRow } from "@/server/utils/deploy";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
export const deploymentRouter = createTRPCRouter({
|
||||
@@ -68,6 +75,63 @@ export const deploymentRouter = createTRPCRouter({
|
||||
}
|
||||
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
|
||||
.input(apiFindAllByType)
|
||||
@@ -79,10 +143,8 @@ export const deploymentRouter = createTRPCRouter({
|
||||
rollback: true,
|
||||
},
|
||||
});
|
||||
|
||||
return deploymentsList;
|
||||
}),
|
||||
|
||||
killProcess: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
findMemberById,
|
||||
updateEnvironmentById,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||
import {
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
apiRemoveEnvironment,
|
||||
apiUpdateEnvironment,
|
||||
} from "@/server/db/schema";
|
||||
import { environments, projects } from "@/server/db/schema";
|
||||
|
||||
// Helper function to filter services within an environment based on user permissions
|
||||
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,
|
||||
findEnvironmentById,
|
||||
findMariadbById,
|
||||
findMemberById,
|
||||
findProjectById,
|
||||
IS_CLOUD,
|
||||
rebuildDatabase,
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
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 { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||
import {
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
apiUpdateMariaDB,
|
||||
mariadb as mariadbTable,
|
||||
} from "@/server/db/schema";
|
||||
import { environments, projects } from "@/server/db/schema";
|
||||
import { cancelJobs } from "@/server/utils/backup";
|
||||
export const mariadbRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
@@ -446,4 +448,102 @@ export const mariadbRouter = createTRPCRouter({
|
||||
await rebuildDatabase(mariadb.mariadbId, "mariadb");
|
||||
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,
|
||||
findBackupsByDbId,
|
||||
findEnvironmentById,
|
||||
findMemberById,
|
||||
findMongoById,
|
||||
findProjectById,
|
||||
IS_CLOUD,
|
||||
@@ -21,7 +22,7 @@ import {
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
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 { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||
import {
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
apiUpdateMongo,
|
||||
mongo as mongoTable,
|
||||
} from "@/server/db/schema";
|
||||
import { environments, projects } from "@/server/db/schema";
|
||||
import { cancelJobs } from "@/server/utils/backup";
|
||||
export const mongoRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
@@ -476,4 +478,97 @@ export const mongoRouter = createTRPCRouter({
|
||||
|
||||
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
|
||||
.input(apiCreateMount)
|
||||
.mutation(async ({ input }) => {
|
||||
await createMount(input);
|
||||
return true;
|
||||
return await createMount(input);
|
||||
}),
|
||||
remove: protectedProcedure
|
||||
.input(apiRemoveMount)
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
deployMySql,
|
||||
findBackupsByDbId,
|
||||
findEnvironmentById,
|
||||
findMemberById,
|
||||
findMySqlById,
|
||||
findProjectById,
|
||||
IS_CLOUD,
|
||||
@@ -21,7 +22,7 @@ import {
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
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 { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||
import {
|
||||
@@ -34,7 +35,9 @@ import {
|
||||
apiSaveEnvironmentVariablesMySql,
|
||||
apiSaveExternalPortMySql,
|
||||
apiUpdateMySql,
|
||||
environments,
|
||||
mysql as mysqlTable,
|
||||
projects,
|
||||
} from "@/server/db/schema";
|
||||
import { cancelJobs } from "@/server/utils/backup";
|
||||
|
||||
@@ -471,4 +474,97 @@ export const mysqlRouter = createTRPCRouter({
|
||||
|
||||
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 };
|
||||
}),
|
||||
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,
|
||||
findBackupsByDbId,
|
||||
findEnvironmentById,
|
||||
findMemberById,
|
||||
findPostgresById,
|
||||
findProjectById,
|
||||
getMountPath,
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
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 { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||
import {
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
apiUpdatePostgres,
|
||||
postgres as postgresTable,
|
||||
} from "@/server/db/schema";
|
||||
import { environments, projects } from "@/server/db/schema";
|
||||
import { cancelJobs } from "@/server/utils/backup";
|
||||
|
||||
export const postgresRouter = createTRPCRouter({
|
||||
@@ -483,4 +485,104 @@ export const postgresRouter = createTRPCRouter({
|
||||
|
||||
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";
|
||||
import { db } from "@dokploy/server/db";
|
||||
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 { z } from "zod";
|
||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||
import {
|
||||
adminProcedure,
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
} from "@/server/api/trpc";
|
||||
import {
|
||||
apiCreateProject,
|
||||
apiFindOneProject,
|
||||
@@ -219,31 +223,69 @@ export const projectRouter = createTRPCRouter({
|
||||
applications.applicationId,
|
||||
accessedServices,
|
||||
),
|
||||
with: { domains: true },
|
||||
columns: {
|
||||
applicationId: true,
|
||||
name: true,
|
||||
applicationStatus: true,
|
||||
},
|
||||
},
|
||||
mariadb: {
|
||||
where: buildServiceFilter(mariadb.mariadbId, accessedServices),
|
||||
columns: {
|
||||
mariadbId: true,
|
||||
name: true,
|
||||
applicationStatus: true,
|
||||
},
|
||||
},
|
||||
mongo: {
|
||||
where: buildServiceFilter(mongo.mongoId, accessedServices),
|
||||
columns: {
|
||||
mongoId: true,
|
||||
name: true,
|
||||
applicationStatus: true,
|
||||
},
|
||||
},
|
||||
mysql: {
|
||||
where: buildServiceFilter(mysql.mysqlId, accessedServices),
|
||||
columns: {
|
||||
mysqlId: true,
|
||||
name: true,
|
||||
applicationStatus: true,
|
||||
},
|
||||
},
|
||||
postgres: {
|
||||
where: buildServiceFilter(
|
||||
postgres.postgresId,
|
||||
accessedServices,
|
||||
),
|
||||
columns: {
|
||||
postgresId: true,
|
||||
name: true,
|
||||
applicationStatus: true,
|
||||
},
|
||||
},
|
||||
redis: {
|
||||
where: buildServiceFilter(redis.redisId, accessedServices),
|
||||
columns: {
|
||||
redisId: true,
|
||||
name: true,
|
||||
applicationStatus: true,
|
||||
},
|
||||
},
|
||||
compose: {
|
||||
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),
|
||||
@@ -255,21 +297,50 @@ export const projectRouter = createTRPCRouter({
|
||||
environments: {
|
||||
with: {
|
||||
applications: {
|
||||
with: {
|
||||
domains: true,
|
||||
columns: {
|
||||
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: {
|
||||
with: {
|
||||
domains: true,
|
||||
columns: {
|
||||
composeId: true,
|
||||
name: true,
|
||||
composeStatus: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
name: true,
|
||||
environmentId: true,
|
||||
isDefault: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
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
|
||||
.input(apiRemoveProject)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createRedis,
|
||||
deployRedis,
|
||||
findEnvironmentById,
|
||||
findMemberById,
|
||||
findProjectById,
|
||||
findRedisById,
|
||||
IS_CLOUD,
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
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 { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||
import {
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
apiUpdateRedis,
|
||||
redis as redisTable,
|
||||
} from "@/server/db/schema";
|
||||
import { environments, projects } from "@/server/db/schema";
|
||||
export const redisRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.input(apiCreateRedis)
|
||||
@@ -450,4 +452,97 @@ export const redisRouter = createTRPCRouter({
|
||||
await rebuildDatabase(redis.redisId, "redis");
|
||||
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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -530,12 +530,12 @@ export const settingsRouter = createTRPCRouter({
|
||||
getOpenApiDocument: protectedProcedure.query(
|
||||
async ({ ctx }): Promise<unknown> => {
|
||||
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, {
|
||||
title: "tRPC OpenAPI",
|
||||
version: packageInfo.version,
|
||||
baseUrl: url,
|
||||
docsUrl: `${url}/trpc/settings.getOpenApiDocument`,
|
||||
docsUrl: `${url}/settings.getOpenApiDocument`,
|
||||
tags: [
|
||||
"admin",
|
||||
"docker",
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
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 [];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,8 +2,22 @@ import path from "node:path";
|
||||
import Docker from "dockerode";
|
||||
|
||||
export const IS_CLOUD = process.env.IS_CLOUD === "true";
|
||||
export const DOCKER_API_VERSION = process.env.DOCKER_API_VERSION;
|
||||
export const DOCKER_HOST = process.env.DOCKER_HOST;
|
||||
export const DOCKER_PORT = process.env.DOCKER_PORT;
|
||||
|
||||
export const CLEANUP_CRON_JOB = "50 23 * * *";
|
||||
export const docker = new Docker();
|
||||
export const docker = new Docker({
|
||||
...(DOCKER_API_VERSION && {
|
||||
version: DOCKER_API_VERSION,
|
||||
}),
|
||||
...(DOCKER_HOST && {
|
||||
host: DOCKER_HOST,
|
||||
}),
|
||||
...(DOCKER_PORT && {
|
||||
port: DOCKER_PORT,
|
||||
}),
|
||||
});
|
||||
|
||||
// When not set, use the legacy default so 2FA remains working for users who
|
||||
// enabled it before BETTER_AUTH_SECRET was introduced .
|
||||
|
||||
@@ -99,17 +99,15 @@ const createSchema = createInsertSchema(mounts, {
|
||||
mountPath: z.string().min(1),
|
||||
mountId: z.string().optional(),
|
||||
filePath: z.string().optional(),
|
||||
serviceType: z
|
||||
.enum([
|
||||
"application",
|
||||
"postgres",
|
||||
"mysql",
|
||||
"mariadb",
|
||||
"mongo",
|
||||
"redis",
|
||||
"compose",
|
||||
])
|
||||
.default("application"),
|
||||
serviceType: z.enum([
|
||||
"application",
|
||||
"postgres",
|
||||
"mysql",
|
||||
"mariadb",
|
||||
"mongo",
|
||||
"redis",
|
||||
"compose",
|
||||
]),
|
||||
});
|
||||
|
||||
export type ServiceType = NonNullable<
|
||||
|
||||
@@ -73,23 +73,25 @@ const { handler, api } = betterAuth({
|
||||
disabled: process.env.NODE_ENV === "production",
|
||||
},
|
||||
async trustedOrigins() {
|
||||
const trustedOrigins = await getTrustedOrigins();
|
||||
if (IS_CLOUD) {
|
||||
return trustedOrigins;
|
||||
return getTrustedOrigins();
|
||||
}
|
||||
const settings = await getWebServerSettings();
|
||||
if (!settings) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
|
||||
...(settings?.host ? [`https://${settings?.host}`] : []),
|
||||
...(process.env.NODE_ENV === "development"
|
||||
const [trustedOrigins, settings] = await Promise.all([
|
||||
getTrustedOrigins(),
|
||||
getWebServerSettings(),
|
||||
]);
|
||||
if (!settings) return [];
|
||||
const devOrigins =
|
||||
process.env.NODE_ENV === "development"
|
||||
? [
|
||||
"http://localhost:3000",
|
||||
"https://absolutely-handy-falcon.ngrok-free.app",
|
||||
]
|
||||
: []),
|
||||
: [];
|
||||
return [
|
||||
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
|
||||
...(settings?.host ? [`https://${settings?.host}`] : []),
|
||||
...devOrigins,
|
||||
...trustedOrigins,
|
||||
];
|
||||
},
|
||||
|
||||
@@ -117,23 +117,33 @@ export const getDokployUrl = async () => {
|
||||
return `http://${settings?.serverIp}:${process.env.PORT}`;
|
||||
};
|
||||
|
||||
export const getTrustedOrigins = async () => {
|
||||
const members = await db.query.member.findMany({
|
||||
where: eq(member.role, "owner"),
|
||||
with: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
const TRUSTED_ORIGINS_CACHE_TTL_MS = 30 * 60_000;
|
||||
let trustedOriginsCache: { data: string[]; expiresAt: number } | null = null;
|
||||
|
||||
if (members.length === 0) {
|
||||
return [];
|
||||
export const getTrustedOrigins = async () => {
|
||||
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;
|
||||
}
|
||||
const trustedOrigins = await runQuery();
|
||||
trustedOriginsCache = {
|
||||
data: trustedOrigins,
|
||||
expiresAt: now + TRUSTED_ORIGINS_CACHE_TTL_MS,
|
||||
};
|
||||
return trustedOrigins;
|
||||
}
|
||||
|
||||
const trustedOrigins = members.flatMap(
|
||||
(member) => member.user.trustedOrigins || [],
|
||||
);
|
||||
|
||||
return Array.from(new Set(trustedOrigins));
|
||||
return runQuery();
|
||||
};
|
||||
|
||||
export const getTrustedProviders = async () => {
|
||||
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
type apiCreateDeploymentSchedule,
|
||||
type apiCreateDeploymentServer,
|
||||
type apiCreateDeploymentVolumeBackup,
|
||||
applications,
|
||||
compose,
|
||||
deployments,
|
||||
environments,
|
||||
projects,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
|
||||
import {
|
||||
@@ -19,7 +23,7 @@ import {
|
||||
} from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
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 Application,
|
||||
@@ -38,6 +42,41 @@ import { findScheduleById } from "./schedule";
|
||||
import { findServerById, type Server } from "./server";
|
||||
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 const findDeploymentById = async (deploymentId: string) => {
|
||||
@@ -738,6 +777,135 @@ export const findAllDeploymentsByComposeId = async (composeId: string) => {
|
||||
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 (
|
||||
deploymentId: string,
|
||||
deploymentData: Partial<Deployment>,
|
||||
|
||||
@@ -34,42 +34,139 @@ export const createEnvironment = async (
|
||||
export const findEnvironmentById = async (environmentId: string) => {
|
||||
const environment = await db.query.environments.findFirst({
|
||||
where: eq(environments.environmentId, environmentId),
|
||||
columns: {
|
||||
name: true,
|
||||
description: true,
|
||||
environmentId: true,
|
||||
isDefault: true,
|
||||
projectId: true,
|
||||
env: true,
|
||||
},
|
||||
with: {
|
||||
applications: {
|
||||
with: {
|
||||
deployments: true,
|
||||
server: true,
|
||||
server: {
|
||||
columns: {
|
||||
name: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
name: true,
|
||||
applicationId: true,
|
||||
createdAt: true,
|
||||
applicationStatus: true,
|
||||
description: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
mariadb: {
|
||||
with: {
|
||||
server: true,
|
||||
server: {
|
||||
columns: {
|
||||
name: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
mariadbId: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
applicationStatus: true,
|
||||
description: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
mongo: {
|
||||
with: {
|
||||
server: true,
|
||||
server: {
|
||||
columns: {
|
||||
name: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
mongoId: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
applicationStatus: true,
|
||||
description: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
mysql: {
|
||||
with: {
|
||||
server: true,
|
||||
server: {
|
||||
columns: {
|
||||
name: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
mysqlId: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
applicationStatus: true,
|
||||
description: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
postgres: {
|
||||
with: {
|
||||
server: true,
|
||||
server: {
|
||||
columns: {
|
||||
name: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
postgresId: true,
|
||||
name: true,
|
||||
description: true,
|
||||
createdAt: true,
|
||||
applicationStatus: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
redis: {
|
||||
with: {
|
||||
server: true,
|
||||
server: {
|
||||
columns: {
|
||||
name: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
redisId: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
applicationStatus: true,
|
||||
description: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
compose: {
|
||||
with: {
|
||||
deployments: true,
|
||||
server: true,
|
||||
server: {
|
||||
columns: {
|
||||
name: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
composeId: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
composeStatus: true,
|
||||
description: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
project: true,
|
||||
@@ -98,6 +195,12 @@ export const findEnvironmentsByProjectId = async (projectId: string) => {
|
||||
compose: true,
|
||||
project: true,
|
||||
},
|
||||
columns: {
|
||||
name: true,
|
||||
description: true,
|
||||
environmentId: true,
|
||||
isDefault: true,
|
||||
},
|
||||
});
|
||||
return projectEnvironments;
|
||||
};
|
||||
@@ -169,6 +272,7 @@ export const duplicateEnvironment = async (
|
||||
name: input.name,
|
||||
description: input.description || originalEnvironment.description,
|
||||
projectId: originalEnvironment.projectId,
|
||||
env: originalEnvironment.env,
|
||||
})
|
||||
.returning()
|
||||
.then((value) => value[0]);
|
||||
|
||||
@@ -16,7 +16,7 @@ function shEscape(s: string | undefined): string {
|
||||
return `'${s.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function safeDockerLoginCommand(
|
||||
export function safeDockerLoginCommand(
|
||||
registry: string | undefined,
|
||||
user: string | undefined,
|
||||
pass: string | undefined,
|
||||
|
||||
@@ -23,7 +23,7 @@ import { findDeploymentById } from "./deployment";
|
||||
import type { Mount } from "./mount";
|
||||
import type { Port } from "./port";
|
||||
import type { Project } from "./project";
|
||||
import type { Registry } from "./registry";
|
||||
import { type Registry, safeDockerLoginCommand } from "./registry";
|
||||
|
||||
export const createRollback = async (
|
||||
input: z.infer<typeof createRollbackSchema>,
|
||||
@@ -111,7 +111,7 @@ const deleteRollbackImage = async (image: string, serverId?: string | null) => {
|
||||
const command = `docker image rm ${image} --force`;
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(command, serverId);
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
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 (
|
||||
appName: string,
|
||||
image: string,
|
||||
@@ -188,6 +205,14 @@ const rollbackApplication = async (
|
||||
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);
|
||||
|
||||
// Use the same configuration as mechanizeDockerContainer
|
||||
|
||||
@@ -67,7 +67,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
|
||||
await execAsync(cleanupCommand);
|
||||
|
||||
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");
|
||||
|
||||
@@ -106,10 +106,6 @@ export const writeDomainsToCompose = async (
|
||||
compose: Compose,
|
||||
domains: Domain[],
|
||||
) => {
|
||||
if (!domains.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const composeConverted = await addDomainToCompose(compose, domains);
|
||||
const path = getComposePath(compose);
|
||||
@@ -145,7 +141,7 @@ export const addDomainToCompose = async (
|
||||
result = await loadDockerCompose(compose);
|
||||
}
|
||||
|
||||
if (!result || domains.length === 0) {
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -138,8 +138,8 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/server
|
||||
'@dokploy/trpc-openapi':
|
||||
specifier: 0.0.16
|
||||
version: 0.0.16(@trpc/server@11.10.0(typescript@5.9.3))(zod-openapi@5.4.6(zod@4.3.6))(zod@4.3.6)
|
||||
specifier: 0.0.17
|
||||
version: 0.0.17(@trpc/server@11.10.0(typescript@5.9.3))(zod-openapi@5.4.6(zod@4.3.6))(zod@4.3.6)
|
||||
'@faker-js/faker':
|
||||
specifier: ^8.4.1
|
||||
version: 8.4.1
|
||||
@@ -1291,8 +1291,8 @@ packages:
|
||||
'@codemirror/view@6.39.15':
|
||||
resolution: {integrity: sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg==}
|
||||
|
||||
'@dokploy/trpc-openapi@0.0.16':
|
||||
resolution: {integrity: sha512-95pukFwMkSKLfnUB21OnYVwmR832NVD6ewI65ZgpxYxTrmdVGCUzvphqs85fiq4UKV3qcuUSq3nn47d3Sh09zg==}
|
||||
'@dokploy/trpc-openapi@0.0.17':
|
||||
resolution: {integrity: sha512-pXWbqx2W0MoWav/wehEqcXzORLgn7PhnmLsZza1v6+lOSo0Vwuu47PrITbRYKQ2zZcR1nTL18TrgPuMzXK23Iw==}
|
||||
peerDependencies:
|
||||
'@trpc/server': ^11.1.0
|
||||
zod: ^4.3.6
|
||||
@@ -8867,7 +8867,7 @@ snapshots:
|
||||
style-mod: 4.1.3
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
'@dokploy/trpc-openapi@0.0.16(@trpc/server@11.10.0(typescript@5.9.3))(zod-openapi@5.4.6(zod@4.3.6))(zod@4.3.6)':
|
||||
'@dokploy/trpc-openapi@0.0.17(@trpc/server@11.10.0(typescript@5.9.3))(zod-openapi@5.4.6(zod@4.3.6))(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@trpc/server': 11.10.0(typescript@5.9.3)
|
||||
co-body: 6.2.0
|
||||
|
||||
Reference in New Issue
Block a user