mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-16 04:35:24 +02:00
Compare commits
265 Commits
v0.28.1
...
dosu/doc-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4406e0542 | ||
|
|
ddfcd1a671 | ||
|
|
401b177a4e | ||
|
|
88b56ca0a2 | ||
|
|
3d48b25f71 | ||
|
|
b7e30d7ec3 | ||
|
|
b1ef5dc2c6 | ||
|
|
3846e41d7f | ||
|
|
ac76f2d97a | ||
|
|
d6056972f4 | ||
|
|
58b9a0d3d0 | ||
|
|
fe78f282f8 | ||
|
|
4941a80b50 | ||
|
|
5ea2ee5dcd | ||
|
|
76d6de5337 | ||
|
|
3374737db6 | ||
|
|
27a67af190 | ||
|
|
7e6a7d2cd4 | ||
|
|
4f5f1ad841 | ||
|
|
fe8d2732fc | ||
|
|
88ad551297 | ||
|
|
f36d011286 | ||
|
|
fb5ee5d6b3 | ||
|
|
3d50cb0ac9 | ||
|
|
c752cf3f9e | ||
|
|
cf25c17c20 | ||
|
|
ae439bcd13 | ||
|
|
b8f069704c | ||
|
|
d4bf6246c3 | ||
|
|
4b6f2c84ac | ||
|
|
116e9d85b7 | ||
|
|
dce1454d4d | ||
|
|
49d79fcd37 | ||
|
|
fa028dcf1e | ||
|
|
a09d7d5663 | ||
|
|
b9aa275759 | ||
|
|
b61ca31981 | ||
|
|
0b08fa9a59 | ||
|
|
ffd7b80410 | ||
|
|
3854dfaade | ||
|
|
bb56a0bae8 | ||
|
|
a03ec76b6f | ||
|
|
9cc8231188 | ||
|
|
ee2240898c | ||
|
|
6fb4a13a18 | ||
|
|
8a8688c011 | ||
|
|
bd18461242 | ||
|
|
7f60000641 | ||
|
|
1d7509dfc2 | ||
|
|
8304513501 | ||
|
|
2809cd690a | ||
|
|
fff91157c4 | ||
|
|
aca1c6f621 | ||
|
|
e9650de794 | ||
|
|
b3579d1321 | ||
|
|
43f9c114c8 | ||
|
|
bc11e8741b | ||
|
|
837373fdc5 | ||
|
|
7d2d7fc005 | ||
|
|
72c15ac18c | ||
|
|
51d744ba45 | ||
|
|
81ecf214f1 | ||
|
|
c2d37631ba | ||
|
|
7c55eba506 | ||
|
|
7878bf29ba | ||
|
|
1b70763ba5 | ||
|
|
e47263ae5f | ||
|
|
b139d6f277 | ||
|
|
cddb06f515 | ||
|
|
d0c92d84ef | ||
|
|
72974e00a6 | ||
|
|
d96e2bbeb7 | ||
|
|
a45d8ee8f4 | ||
|
|
9067452a38 | ||
|
|
1fa4d5b2ba | ||
|
|
bade36ea9d | ||
|
|
0c22041623 | ||
|
|
cccee05173 | ||
|
|
9f9c8fccf2 | ||
|
|
ad2e53a67a | ||
|
|
00f3853bd7 | ||
|
|
2880327e94 | ||
|
|
827b84f57e | ||
|
|
11aa8fe0c5 | ||
|
|
b9ac720d99 | ||
|
|
77b0ff7bbf | ||
|
|
e7af2c0ebd | ||
|
|
6a1bedb90f | ||
|
|
a2f142174b | ||
|
|
f4ce304a04 | ||
|
|
bb521f3e7e | ||
|
|
baaa470234 | ||
|
|
4871520dbb | ||
|
|
dad49ec96f | ||
|
|
ce4e37c75b | ||
|
|
c317ec39cb | ||
|
|
a4e9c6e890 | ||
|
|
72fb85f616 | ||
|
|
1e7a6f2071 | ||
|
|
5ffd664570 | ||
|
|
947100c041 | ||
|
|
5410a56638 | ||
|
|
8127dc4536 | ||
|
|
ee42a393aa | ||
|
|
2f37235aea | ||
|
|
290267bca4 | ||
|
|
8eace173b9 | ||
|
|
c9a9ed8164 | ||
|
|
30428053e8 | ||
|
|
1c0dbbcfd6 | ||
|
|
178f4fbdf7 | ||
|
|
2c07a4b2e3 | ||
|
|
75a797097b | ||
|
|
2879816e41 | ||
|
|
3501996b9e | ||
|
|
47556a6486 | ||
|
|
e554adc376 | ||
|
|
1804b935f6 | ||
|
|
985c9102da | ||
|
|
2e03cf3d48 | ||
|
|
33532d3cf7 | ||
|
|
a6999b1cf2 | ||
|
|
f5d18d6f9b | ||
|
|
e3ff7ef3e3 | ||
|
|
b84bc9b7c6 | ||
|
|
5e6e5ba9d8 | ||
|
|
de201d0b0a | ||
|
|
6866e2b63a | ||
|
|
3e4a1b92eb | ||
|
|
b9ca6ea9db | ||
|
|
f1d4543d5e | ||
|
|
d8c7c1eaf4 | ||
|
|
4330d7bd99 | ||
|
|
6e67864204 | ||
|
|
2102840bb9 | ||
|
|
1203d0589b | ||
|
|
30f061e774 | ||
|
|
c00aa6acbf | ||
|
|
8e9ab98a7a | ||
|
|
ce82e2322b | ||
|
|
ec7df05990 | ||
|
|
75a4e8e8ef | ||
|
|
b4319c7ea2 | ||
|
|
e9787b753d | ||
|
|
b419294b09 | ||
|
|
922b4d58f1 | ||
|
|
dc8ff78ee5 | ||
|
|
735c9952d8 | ||
|
|
21821295e3 | ||
|
|
a8467e80e8 | ||
|
|
95e14b4199 | ||
|
|
076262e479 | ||
|
|
653e5fa3a0 | ||
|
|
66931fe24f | ||
|
|
7feb4061f8 | ||
|
|
c4f4db3ebc | ||
|
|
4882bd25ad | ||
|
|
7a8f2e53d5 | ||
|
|
50182a8048 | ||
|
|
35d35028f6 | ||
|
|
a5a4a1a818 | ||
|
|
c106d13ab5 | ||
|
|
808001d8de | ||
|
|
ce24eadbb4 | ||
|
|
b87f8cc5d8 | ||
|
|
f650200771 | ||
|
|
f961dc6e7a | ||
|
|
4be25da185 | ||
|
|
675c1d7a7d | ||
|
|
28cc361c47 | ||
|
|
cedec5239f | ||
|
|
2f4cbbd3ac | ||
|
|
38b20450dc | ||
|
|
49f43ab3fb | ||
|
|
2eae756cec | ||
|
|
70c261d021 | ||
|
|
9ae2ebff46 | ||
|
|
8ce880d108 | ||
|
|
34304526b1 | ||
|
|
a16c4c1294 | ||
|
|
d1c4ac20e3 | ||
|
|
0195119a86 | ||
|
|
48a577e792 | ||
|
|
bf7a75dd9f | ||
|
|
d316aa4401 | ||
|
|
f1b2cc35b3 | ||
|
|
d2fabc998d | ||
|
|
7185047eb7 | ||
|
|
c75cfa2d69 | ||
|
|
7121fbe50a | ||
|
|
36cf3a69fc | ||
|
|
c34a01a173 | ||
|
|
9ac147a140 | ||
|
|
20f79ac655 | ||
|
|
6f21f1cc1f | ||
|
|
af76548482 | ||
|
|
13638d0f04 | ||
|
|
edceebec7e | ||
|
|
7599565e73 | ||
|
|
08c9113405 | ||
|
|
1014d4674c | ||
|
|
1c5b92729a | ||
|
|
39b40c58bb | ||
|
|
1861e10b2a | ||
|
|
964e3c4150 | ||
|
|
86feda1679 | ||
|
|
f95b29a450 | ||
|
|
a1cf5520a9 | ||
|
|
e05f31d8c6 | ||
|
|
cc3b902d1e | ||
|
|
6c1f2372ed | ||
|
|
7da69862e1 | ||
|
|
612e73bb80 | ||
|
|
a360a259f5 | ||
|
|
149293f4d3 | ||
|
|
a8a5e1c6f1 | ||
|
|
4ede21eda9 | ||
|
|
e275e9162e | ||
|
|
60a6dc5fab | ||
|
|
705c5bc1c9 | ||
|
|
8d56544c1d | ||
|
|
ca527ab6ff | ||
|
|
439fa17292 | ||
|
|
096c04486c | ||
|
|
c9e1079076 | ||
|
|
e29a86a85f | ||
|
|
f9dedd979e | ||
|
|
1ba0eb0c2e | ||
|
|
d7dc10993e | ||
|
|
2a5d3975e8 | ||
|
|
9f3356ddb4 | ||
|
|
f5674f5bf8 | ||
|
|
cbbf7f3a6d | ||
|
|
ebf5f486bc | ||
|
|
b1b1dbc1ce | ||
|
|
355d46948b | ||
|
|
938b0b4ed3 | ||
|
|
ebbbd39065 | ||
|
|
1f3936fcad | ||
|
|
e4d9fd37b9 | ||
|
|
0df6cc5395 | ||
|
|
2b4604dc0c | ||
|
|
1da9ef8e69 | ||
|
|
e049352f6d | ||
|
|
1cb1b5083f | ||
|
|
affd17d788 | ||
|
|
68f6d4a558 | ||
|
|
970905198b | ||
|
|
a0c87358eb | ||
|
|
8ee38a1463 | ||
|
|
e726bf31f6 | ||
|
|
f4248760a8 | ||
|
|
b715e21236 | ||
|
|
71d3a43fd7 | ||
|
|
02f0b0b1a4 | ||
|
|
2dffdffaf3 | ||
|
|
096235f8a1 | ||
|
|
c3b79c115d | ||
|
|
1fb8445165 | ||
|
|
53a11b81d6 | ||
|
|
307916a49a | ||
|
|
293160eb55 | ||
|
|
95999df13e | ||
|
|
803577a403 | ||
|
|
4b1f359cb6 |
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -7,7 +7,7 @@ Please describe in a short paragraph what this PR is about.
|
||||
Before submitting this PR, please make sure that:
|
||||
|
||||
- [ ] You created a dedicated branch based on the `canary` branch.
|
||||
- [ ] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
|
||||
- [ ] You have read the suggestions in the CONTRIBUTING.md file https://github.com/MkinG2k0/dokploy/blob/canary/CONTRIBUTING.md#pull-request
|
||||
- [ ] You have tested this PR in your local instance. If you have not tested it yet, please do so before submitting. This helps avoid wasting maintainers' time reviewing code that has not been verified by you.
|
||||
|
||||
## Issues related (if applicable)
|
||||
|
||||
1
.github/workflows/pr-quality.yml
vendored
1
.github/workflows/pr-quality.yml
vendored
@@ -16,7 +16,6 @@ jobs:
|
||||
steps:
|
||||
- uses: peakoss/anti-slop@v0
|
||||
with:
|
||||
max-failures: 4
|
||||
blocked-commit-authors: "claude,copilot"
|
||||
require-description: true
|
||||
min-account-age: 5
|
||||
|
||||
@@ -99,7 +99,14 @@ pnpm run dokploy:build
|
||||
|
||||
## Docker
|
||||
|
||||
To build the docker image
|
||||
To build the docker image first run commands to copy .env files
|
||||
|
||||
```bash
|
||||
cp apps/dokploy/.env.production.example .env.production
|
||||
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
||||
```
|
||||
|
||||
then run build command
|
||||
|
||||
```bash
|
||||
pnpm run docker:build
|
||||
|
||||
@@ -19,7 +19,7 @@ Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies th
|
||||
Dokploy includes multiple features to make your life easier.
|
||||
|
||||
- **Applications**: Deploy any type of application (Node.js, PHP, Python, Go, Ruby, etc.).
|
||||
- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, and Redis.
|
||||
- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, libsql, and Redis.
|
||||
- **Backups**: Automate backups for databases to an external storage destination.
|
||||
- **Docker Compose**: Native support for Docker Compose to manage complex applications.
|
||||
- **Multi Node**: Scale applications to multiple nodes using Docker Swarm to manage the cluster.
|
||||
|
||||
@@ -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
|
||||
@@ -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([]),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
144
apps/dokploy/__test__/permissions/check-permission.test.ts
Normal file
144
apps/dokploy/__test__/permissions/check-permission.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockMemberData = (
|
||||
role: string,
|
||||
overrides: Record<string, boolean> = {},
|
||||
) => ({
|
||||
id: "member-1",
|
||||
role,
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
accessedProjects: [] as string[],
|
||||
accessedServices: [] as string[],
|
||||
accessedEnvironments: [] as string[],
|
||||
canCreateProjects: overrides.canCreateProjects ?? false,
|
||||
canDeleteProjects: overrides.canDeleteProjects ?? false,
|
||||
canCreateServices: overrides.canCreateServices ?? false,
|
||||
canDeleteServices: overrides.canDeleteServices ?? false,
|
||||
canCreateEnvironments: overrides.canCreateEnvironments ?? false,
|
||||
canDeleteEnvironments: overrides.canDeleteEnvironments ?? false,
|
||||
canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false,
|
||||
canAccessToDocker: overrides.canAccessToDocker ?? false,
|
||||
canAccessToAPI: overrides.canAccessToAPI ?? false,
|
||||
canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false,
|
||||
canAccessToGitProviders: overrides.canAccessToGitProviders ?? false,
|
||||
user: { id: "user-1", email: "test@test.com" },
|
||||
});
|
||||
|
||||
let memberToReturn: ReturnType<typeof mockMemberData> =
|
||||
mockMemberData("member");
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
member: {
|
||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
organizationRole: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: vi.fn(() => Promise.resolve(false)),
|
||||
}));
|
||||
|
||||
const { checkPermission } = await import("@dokploy/server/services/permission");
|
||||
|
||||
const ctx = {
|
||||
user: { id: "user-1" },
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("static roles bypass enterprise resources", () => {
|
||||
it("owner bypasses deployment.read", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
await expect(
|
||||
checkPermission(ctx, { deployment: ["read"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("admin bypasses backup.create", async () => {
|
||||
memberToReturn = mockMemberData("admin");
|
||||
await expect(
|
||||
checkPermission(ctx, { backup: ["create"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member bypasses schedule.delete", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { schedule: ["delete"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member bypasses multiple enterprise permissions at once", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, {
|
||||
deployment: ["read"],
|
||||
backup: ["create"],
|
||||
domain: ["delete"],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("static roles validate free-tier resources", () => {
|
||||
it("owner passes project.create", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
await expect(
|
||||
checkPermission(ctx, { project: ["create"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member fails project.create (no legacy override)", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { project: ["create"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member passes service.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { service: ["read"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member fails service.create", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { service: ["create"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy boolean overrides for member", () => {
|
||||
it("member passes project.create with canCreateProjects=true", async () => {
|
||||
memberToReturn = mockMemberData("member", { canCreateProjects: true });
|
||||
await expect(
|
||||
checkPermission(ctx, { project: ["create"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member passes docker.read with canAccessToDocker=true", async () => {
|
||||
memberToReturn = mockMemberData("member", { canAccessToDocker: true });
|
||||
await expect(
|
||||
checkPermission(ctx, { docker: ["read"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member fails docker.read with canAccessToDocker=false", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(checkPermission(ctx, { docker: ["read"] })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
enterpriseOnlyResources,
|
||||
statements,
|
||||
} from "@dokploy/server/lib/access-control";
|
||||
|
||||
const FREE_TIER_RESOURCES = [
|
||||
"organization",
|
||||
"member",
|
||||
"invitation",
|
||||
"team",
|
||||
"ac",
|
||||
"project",
|
||||
"service",
|
||||
"environment",
|
||||
"docker",
|
||||
"sshKeys",
|
||||
"gitProviders",
|
||||
"traefikFiles",
|
||||
"api",
|
||||
];
|
||||
|
||||
const ENTERPRISE_RESOURCES = [
|
||||
"volume",
|
||||
"deployment",
|
||||
"envVars",
|
||||
"projectEnvVars",
|
||||
"environmentEnvVars",
|
||||
"server",
|
||||
"registry",
|
||||
"certificate",
|
||||
"backup",
|
||||
"volumeBackup",
|
||||
"schedule",
|
||||
"domain",
|
||||
"destination",
|
||||
"notification",
|
||||
"tag",
|
||||
"logs",
|
||||
"monitoring",
|
||||
"auditLog",
|
||||
];
|
||||
|
||||
describe("enterpriseOnlyResources set", () => {
|
||||
it("contains all enterprise resources", () => {
|
||||
for (const resource of ENTERPRISE_RESOURCES) {
|
||||
expect(enterpriseOnlyResources.has(resource)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT contain free-tier resources", () => {
|
||||
for (const resource of FREE_TIER_RESOURCES) {
|
||||
expect(enterpriseOnlyResources.has(resource)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("every resource in statements is either free or enterprise", () => {
|
||||
const allResources = Object.keys(statements);
|
||||
for (const resource of allResources) {
|
||||
const isFree = FREE_TIER_RESOURCES.includes(resource);
|
||||
const isEnterprise = enterpriseOnlyResources.has(resource);
|
||||
expect(isFree || isEnterprise).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("free and enterprise sets don't overlap", () => {
|
||||
for (const resource of FREE_TIER_RESOURCES) {
|
||||
expect(enterpriseOnlyResources.has(resource)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("all statement resources are accounted for", () => {
|
||||
const allResources = Object.keys(statements);
|
||||
const categorized = [...FREE_TIER_RESOURCES, ...ENTERPRISE_RESOURCES];
|
||||
for (const resource of allResources) {
|
||||
expect(categorized).toContain(resource);
|
||||
}
|
||||
});
|
||||
});
|
||||
161
apps/dokploy/__test__/permissions/resolve-permissions.test.ts
Normal file
161
apps/dokploy/__test__/permissions/resolve-permissions.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockMemberData = (
|
||||
role: string,
|
||||
overrides: Record<string, boolean> = {},
|
||||
) => ({
|
||||
id: "member-1",
|
||||
role,
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
accessedProjects: [] as string[],
|
||||
accessedServices: [] as string[],
|
||||
accessedEnvironments: [] as string[],
|
||||
canCreateProjects: overrides.canCreateProjects ?? false,
|
||||
canDeleteProjects: overrides.canDeleteProjects ?? false,
|
||||
canCreateServices: overrides.canCreateServices ?? false,
|
||||
canDeleteServices: overrides.canDeleteServices ?? false,
|
||||
canCreateEnvironments: overrides.canCreateEnvironments ?? false,
|
||||
canDeleteEnvironments: overrides.canDeleteEnvironments ?? false,
|
||||
canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false,
|
||||
canAccessToDocker: overrides.canAccessToDocker ?? false,
|
||||
canAccessToAPI: overrides.canAccessToAPI ?? false,
|
||||
canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false,
|
||||
canAccessToGitProviders: overrides.canAccessToGitProviders ?? false,
|
||||
user: { id: "user-1", email: "test@test.com" },
|
||||
});
|
||||
|
||||
let memberToReturn: ReturnType<typeof mockMemberData> =
|
||||
mockMemberData("member");
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
member: {
|
||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
organizationRole: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: vi.fn(() => Promise.resolve(false)),
|
||||
}));
|
||||
|
||||
const { resolvePermissions } = await import(
|
||||
"@dokploy/server/services/permission"
|
||||
);
|
||||
const { enterpriseOnlyResources, statements } = await import(
|
||||
"@dokploy/server/lib/access-control"
|
||||
);
|
||||
|
||||
const ctx = {
|
||||
user: { id: "user-1" },
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("enterprise resources for static roles", () => {
|
||||
it("owner gets true for all enterprise resources", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
|
||||
for (const resource of enterpriseOnlyResources) {
|
||||
const actions = statements[resource as keyof typeof statements];
|
||||
for (const action of actions) {
|
||||
expect((perms as any)[resource][action]).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("admin gets true for all enterprise resources", async () => {
|
||||
memberToReturn = mockMemberData("admin");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
|
||||
for (const resource of enterpriseOnlyResources) {
|
||||
const actions = statements[resource as keyof typeof statements];
|
||||
for (const action of actions) {
|
||||
expect((perms as any)[resource][action]).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("member gets true for service-level enterprise resources", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
|
||||
expect(perms.deployment.read).toBe(true);
|
||||
expect(perms.deployment.create).toBe(true);
|
||||
expect(perms.domain.read).toBe(true);
|
||||
expect(perms.backup.read).toBe(true);
|
||||
expect(perms.logs.read).toBe(true);
|
||||
expect(perms.monitoring.read).toBe(true);
|
||||
});
|
||||
|
||||
it("member gets false for org-level enterprise resources", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
|
||||
expect(perms.server.read).toBe(false);
|
||||
expect(perms.registry.read).toBe(false);
|
||||
expect(perms.certificate.read).toBe(false);
|
||||
expect(perms.destination.read).toBe(false);
|
||||
expect(perms.notification.read).toBe(false);
|
||||
expect(perms.auditLog.read).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("free-tier resources for member", () => {
|
||||
it("member gets service.read=true", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.service.read).toBe(true);
|
||||
});
|
||||
|
||||
it("member gets project.create=false without legacy override", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.project.create).toBe(false);
|
||||
});
|
||||
|
||||
it("member gets project.create=true with canCreateProjects", async () => {
|
||||
memberToReturn = mockMemberData("member", { canCreateProjects: true });
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.project.create).toBe(true);
|
||||
});
|
||||
|
||||
it("member gets docker.read=false without legacy override", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.docker.read).toBe(false);
|
||||
});
|
||||
|
||||
it("member gets docker.read=true with canAccessToDocker", async () => {
|
||||
memberToReturn = mockMemberData("member", { canAccessToDocker: true });
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.docker.read).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("free-tier resources for owner", () => {
|
||||
it("owner gets all free-tier permissions as true", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.project.create).toBe(true);
|
||||
expect(perms.project.delete).toBe(true);
|
||||
expect(perms.service.create).toBe(true);
|
||||
expect(perms.service.read).toBe(true);
|
||||
expect(perms.service.delete).toBe(true);
|
||||
expect(perms.docker.read).toBe(true);
|
||||
expect(perms.traefikFiles.read).toBe(true);
|
||||
expect(perms.traefikFiles.write).toBe(true);
|
||||
});
|
||||
});
|
||||
132
apps/dokploy/__test__/permissions/service-access.test.ts
Normal file
132
apps/dokploy/__test__/permissions/service-access.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockMemberData = (
|
||||
role: string,
|
||||
accessedServices: string[] = [],
|
||||
accessedProjects: string[] = [],
|
||||
) => ({
|
||||
id: "member-1",
|
||||
role,
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
accessedProjects,
|
||||
accessedServices,
|
||||
accessedEnvironments: [] as string[],
|
||||
canCreateProjects: false,
|
||||
canDeleteProjects: false,
|
||||
canCreateServices: false,
|
||||
canDeleteServices: false,
|
||||
canCreateEnvironments: false,
|
||||
canDeleteEnvironments: false,
|
||||
canAccessToTraefikFiles: false,
|
||||
canAccessToDocker: false,
|
||||
canAccessToAPI: false,
|
||||
canAccessToSSHKeys: false,
|
||||
canAccessToGitProviders: false,
|
||||
user: { id: "user-1", email: "test@test.com" },
|
||||
});
|
||||
|
||||
let memberToReturn: ReturnType<typeof mockMemberData> =
|
||||
mockMemberData("member");
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
member: {
|
||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
organizationRole: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: vi.fn(() => Promise.resolve(false)),
|
||||
}));
|
||||
|
||||
const { checkServicePermissionAndAccess, checkServiceAccess } = await import(
|
||||
"@dokploy/server/services/permission"
|
||||
);
|
||||
|
||||
const ctx = {
|
||||
user: { id: "user-1" },
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("checkServicePermissionAndAccess", () => {
|
||||
it("owner bypasses accessedServices check", async () => {
|
||||
memberToReturn = mockMemberData("owner", []);
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||
deployment: ["read"],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("admin bypasses accessedServices check", async () => {
|
||||
memberToReturn = mockMemberData("admin", []);
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||
backup: ["create"],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member with access to service passes", async () => {
|
||||
memberToReturn = mockMemberData("member", ["service-123"]);
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||
deployment: ["read"],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member WITHOUT access to service fails", async () => {
|
||||
memberToReturn = mockMemberData("member", ["other-service"]);
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||
deployment: ["read"],
|
||||
}),
|
||||
).rejects.toThrow("You don't have access to this service");
|
||||
});
|
||||
|
||||
it("member with empty accessedServices fails", async () => {
|
||||
memberToReturn = mockMemberData("member", []);
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||
domain: ["delete"],
|
||||
}),
|
||||
).rejects.toThrow("You don't have access to this service");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkServiceAccess", () => {
|
||||
it("member with service access passes read check", async () => {
|
||||
memberToReturn = mockMemberData("member", ["app-1"]);
|
||||
await expect(
|
||||
checkServiceAccess(ctx, "app-1", "read"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member without service access fails read check", async () => {
|
||||
memberToReturn = mockMemberData("member", []);
|
||||
await expect(checkServiceAccess(ctx, "app-1", "read")).rejects.toThrow(
|
||||
"You don't have access to this service",
|
||||
);
|
||||
});
|
||||
|
||||
it("owner bypasses all access checks", async () => {
|
||||
memberToReturn = mockMemberData("owner", [], []);
|
||||
await expect(
|
||||
checkServiceAccess(ctx, "project-1", "create"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -48,6 +48,20 @@ const baseSettings: WebServerSettings = {
|
||||
urlCallback: "",
|
||||
},
|
||||
},
|
||||
whitelabelingConfig: {
|
||||
appName: null,
|
||||
appDescription: null,
|
||||
logoUrl: null,
|
||||
faviconUrl: null,
|
||||
customCss: null,
|
||||
loginLogoUrl: null,
|
||||
supportUrl: null,
|
||||
docsUrl: null,
|
||||
errorPageTitle: null,
|
||||
errorPageDescription: null,
|
||||
metaTitle: null,
|
||||
footerText: null,
|
||||
},
|
||||
cleanupCacheApplications: false,
|
||||
cleanupCacheOnCompose: false,
|
||||
cleanupCacheOnPreviews: false,
|
||||
|
||||
@@ -110,16 +110,16 @@ const menuItems: MenuItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const hasStopGracePeriodSwarm = (
|
||||
value: unknown,
|
||||
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"stopGracePeriodSwarm" in value;
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "application"
|
||||
| "libsql"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "postgres"
|
||||
| "redis";
|
||||
}
|
||||
|
||||
export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
|
||||
@@ -37,7 +37,7 @@ import { AddSwarmSettings } from "./modify-swarm-settings";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type: "application" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis";
|
||||
}
|
||||
|
||||
const AddRedirectchema = z.object({
|
||||
@@ -49,15 +49,15 @@ type AddCommand = z.infer<typeof AddRedirectchema>;
|
||||
|
||||
export const ShowClusterSettings = ({ id, type }: Props) => {
|
||||
const queryMap = {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -65,12 +65,13 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
||||
const { data: registries } = api.registry.all.useQuery();
|
||||
|
||||
const mutationMap = {
|
||||
application: () => api.application.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync, isPending } = mutationMap[type]
|
||||
@@ -105,11 +106,11 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
||||
const onSubmit = async (data: AddCommand) => {
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
mysqlId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
...(type === "application"
|
||||
? {
|
||||
registryId:
|
||||
|
||||
@@ -28,7 +28,14 @@ export const endpointSpecFormSchema = z.object({
|
||||
|
||||
interface EndpointSpecFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
||||
@@ -44,6 +51,7 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -56,6 +64,7 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -94,6 +103,7 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
endpointSpecSwarm: hasAnyValue ? formData : null,
|
||||
});
|
||||
|
||||
|
||||
@@ -26,7 +26,14 @@ export const healthCheckFormSchema = z.object({
|
||||
|
||||
interface HealthCheckFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
@@ -42,6 +49,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -54,6 +62,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -104,6 +113,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
healthCheckSwarm: hasAnyValue ? formData : null,
|
||||
});
|
||||
|
||||
|
||||
@@ -29,7 +29,14 @@ export const labelsFormSchema = z.object({
|
||||
|
||||
interface LabelsFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
||||
@@ -45,6 +52,7 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -57,6 +65,7 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -112,6 +121,7 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
labelsSwarm: labelsToSend,
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,14 @@ import { api } from "@/utils/api";
|
||||
|
||||
interface ModeFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const ModeForm = ({ id, type }: ModeFormProps) => {
|
||||
@@ -39,6 +46,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -51,6 +59,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -95,6 +104,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
modeSwarm: null,
|
||||
});
|
||||
toast.success("Mode updated successfully");
|
||||
@@ -122,6 +132,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
modeSwarm: modeData,
|
||||
});
|
||||
|
||||
|
||||
@@ -35,7 +35,14 @@ export const networkFormSchema = z.object({
|
||||
|
||||
interface NetworkFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
||||
@@ -51,6 +58,7 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -63,6 +71,7 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -132,6 +141,7 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
networkSwarm: networksToSend,
|
||||
});
|
||||
|
||||
|
||||
@@ -34,7 +34,14 @@ export const placementFormSchema = z.object({
|
||||
|
||||
interface PlacementFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
||||
@@ -50,6 +57,7 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -62,6 +70,7 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -114,6 +123,7 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
placementSwarm: hasAnyValue
|
||||
? {
|
||||
...formData,
|
||||
|
||||
@@ -32,7 +32,14 @@ export const restartPolicyFormSchema = z.object({
|
||||
|
||||
interface RestartPolicyFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
||||
@@ -48,6 +55,7 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -60,6 +68,7 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -104,6 +113,7 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
restartPolicySwarm: hasAnyValue ? formData : null,
|
||||
});
|
||||
|
||||
|
||||
@@ -34,7 +34,14 @@ export const rollbackConfigFormSchema = z.object({
|
||||
|
||||
interface RollbackConfigFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
||||
@@ -50,6 +57,7 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -62,6 +70,7 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -103,6 +112,7 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
rollbackConfigSwarm: (hasAnyValue ? formData : null) as any,
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,14 @@ const hasStopGracePeriodSwarm = (
|
||||
|
||||
interface StopGracePeriodFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||
@@ -39,6 +46,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -51,6 +59,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -88,6 +97,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
stopGracePeriodSwarm: formData.value,
|
||||
});
|
||||
|
||||
|
||||
@@ -34,7 +34,14 @@ export const updateConfigFormSchema = z.object({
|
||||
|
||||
interface UpdateConfigFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
type:
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "redis"
|
||||
| "application"
|
||||
| "libsql";
|
||||
}
|
||||
|
||||
export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
||||
@@ -50,6 +57,7 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -62,6 +70,7 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
@@ -109,6 +118,7 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
libsqlId: id || "",
|
||||
updateConfigSwarm: (hasAnyValue ? formData : null) as any,
|
||||
});
|
||||
|
||||
|
||||
@@ -89,12 +89,13 @@ const ULIMIT_PRESETS = [
|
||||
];
|
||||
|
||||
export type ServiceType =
|
||||
| "postgres"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "mysql"
|
||||
| "application"
|
||||
| "libsql"
|
||||
| "mariadb"
|
||||
| "application";
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "postgres"
|
||||
| "redis";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
@@ -105,27 +106,29 @@ type AddResources = z.infer<typeof addResourcesSchema>;
|
||||
|
||||
export const ShowResources = ({ id, type }: Props) => {
|
||||
const queryMap = {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||
|
||||
const mutationMap = {
|
||||
application: () => api.application.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync, isPending } = mutationMap[type]
|
||||
@@ -155,19 +158,20 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
cpuReservation: data?.cpuReservation || undefined,
|
||||
memoryLimit: data?.memoryLimit || undefined,
|
||||
memoryReservation: data?.memoryReservation || undefined,
|
||||
ulimitsSwarm: data?.ulimitsSwarm || [],
|
||||
ulimitsSwarm: (data as any)?.ulimitsSwarm || [],
|
||||
});
|
||||
}
|
||||
}, [data, form, form.reset]);
|
||||
|
||||
const onSubmit = async (formData: AddResources) => {
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
libsqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
mysqlId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
applicationId: id || "",
|
||||
cpuLimit: formData.cpuLimit || null,
|
||||
cpuReservation: formData.cpuReservation || null,
|
||||
memoryLimit: formData.memoryLimit || null,
|
||||
|
||||
@@ -15,13 +15,17 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowTraefikConfig = ({ applicationId }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canRead = permissions?.traefikFiles.read ?? false;
|
||||
const { data, isPending } = api.application.readTraefikConfig.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{ enabled: !!applicationId },
|
||||
{ enabled: !!applicationId && canRead },
|
||||
);
|
||||
|
||||
if (!canRead) return null;
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row justify-between">
|
||||
|
||||
@@ -60,6 +60,8 @@ export const validateAndFormatYAML = (yamlText: string) => {
|
||||
};
|
||||
|
||||
export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canWrite = permissions?.traefikFiles.write ?? false;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
|
||||
const { data, refetch } = api.application.readTraefikConfig.useQuery(
|
||||
@@ -125,9 +127,11 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button isLoading={isPending}>Modify</Button>
|
||||
</DialogTrigger>
|
||||
{canWrite && (
|
||||
<DialogTrigger asChild>
|
||||
<Button isLoading={isPending}>Modify</Button>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
<DialogContent className="sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update traefik config</DialogTitle>
|
||||
|
||||
@@ -34,13 +34,13 @@ interface Props {
|
||||
serviceId: string;
|
||||
serviceType:
|
||||
| "application"
|
||||
| "postgres"
|
||||
| "redis"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "mysql"
|
||||
| "compose"
|
||||
| "libsql"
|
||||
| "mariadb"
|
||||
| "compose";
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "postgres"
|
||||
| "redis";
|
||||
refetch: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -21,24 +21,33 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowVolumes = ({ id, type }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canRead = permissions?.volume.read ?? false;
|
||||
const canCreate = permissions?.volume.create ?? false;
|
||||
const canDelete = permissions?.volume.delete ?? false;
|
||||
|
||||
if (!canRead) return null;
|
||||
|
||||
const queryMap = {
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
compose: () =>
|
||||
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
compose: () =>
|
||||
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||
const { mutateAsync: deleteVolume, isPending: isRemoving } =
|
||||
api.mounts.remove.useMutation();
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
|
||||
@@ -50,7 +59,7 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{data && data?.mounts.length > 0 && (
|
||||
{canCreate && data && data?.mounts.length > 0 && (
|
||||
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
||||
Add Volume
|
||||
</AddVolumes>
|
||||
@@ -63,9 +72,11 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
||||
<span className="text-base text-muted-foreground">
|
||||
No volumes/mounts configured
|
||||
</span>
|
||||
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
||||
Add Volume
|
||||
</AddVolumes>
|
||||
{canCreate && (
|
||||
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
||||
Add Volume
|
||||
</AddVolumes>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col pt-2 gap-4">
|
||||
@@ -130,38 +141,42 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-1">
|
||||
<UpdateVolume
|
||||
mountId={mount.mountId}
|
||||
type={mount.type}
|
||||
refetch={refetch}
|
||||
serviceType={type}
|
||||
/>
|
||||
<DialogAction
|
||||
title="Delete Volume"
|
||||
description="Are you sure you want to delete this volume?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteVolume({
|
||||
mountId: mount.mountId,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success("Volume deleted successfully");
|
||||
{canCreate && (
|
||||
<UpdateVolume
|
||||
mountId={mount.mountId}
|
||||
type={mount.type}
|
||||
refetch={refetch}
|
||||
serviceType={type}
|
||||
/>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DialogAction
|
||||
title="Delete Volume"
|
||||
description="Are you sure you want to delete this volume?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteVolume({
|
||||
mountId: mount.mountId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting volume");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success("Volume deleted successfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting volume");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,13 +67,13 @@ interface Props {
|
||||
refetch: () => void;
|
||||
serviceType:
|
||||
| "application"
|
||||
| "postgres"
|
||||
| "redis"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "mysql"
|
||||
| "compose"
|
||||
| "libsql"
|
||||
| "mariadb"
|
||||
| "compose";
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "postgres"
|
||||
| "redis";
|
||||
}
|
||||
|
||||
export const UpdateVolume = ({
|
||||
@@ -253,7 +253,7 @@ export const UpdateVolume = ({
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem className="max-w-full max-w-[45rem]">
|
||||
<FormItem className="w-full max-w-[45rem]">
|
||||
<FormLabel>Content</FormLabel>
|
||||
<FormControl>
|
||||
<FormControl>
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
Copy,
|
||||
Loader2,
|
||||
RefreshCcw,
|
||||
RocketIcon,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
@@ -97,6 +99,12 @@ export const ShowDeployments = ({
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const webhookUrl = useMemo(
|
||||
() =>
|
||||
`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`,
|
||||
[url, refreshToken, type],
|
||||
);
|
||||
|
||||
const MAX_DESCRIPTION_LENGTH = 200;
|
||||
|
||||
const truncateDescription = (description: string): string => {
|
||||
@@ -224,11 +232,27 @@ export const ShowDeployments = ({
|
||||
<div className="flex flex-row items-center gap-2 flex-wrap">
|
||||
<span>Webhook URL: </span>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span className="break-all text-muted-foreground">
|
||||
{`${url}/api/deploy${
|
||||
type === "compose" ? "/compose" : ""
|
||||
}/${refreshToken}`}
|
||||
</span>
|
||||
<Badge
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Copy webhook URL to clipboard"
|
||||
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer whitespace-normal break-all"
|
||||
variant="outline"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
copy(webhookUrl);
|
||||
toast.success("Copied to clipboard.");
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
copy(webhookUrl);
|
||||
toast.success("Copied to clipboard.");
|
||||
}}
|
||||
>
|
||||
{webhookUrl}
|
||||
<Copy className="h-4 w-4 ml-2" />
|
||||
</Badge>
|
||||
{(type === "application" || type === "compose") && (
|
||||
<RefreshToken id={id} type={type} />
|
||||
)}
|
||||
|
||||
@@ -50,6 +50,9 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowDomains = ({ id, type }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canCreateDomain = permissions?.domain.create ?? false;
|
||||
const canDeleteDomain = permissions?.domain.delete ?? false;
|
||||
const { data: application } =
|
||||
type === "application"
|
||||
? api.application.one.useQuery(
|
||||
@@ -149,7 +152,7 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-4 flex-wrap">
|
||||
{data && data?.length > 0 && (
|
||||
{canCreateDomain && data && data?.length > 0 && (
|
||||
<AddDomain id={id} type={type}>
|
||||
<Button>
|
||||
<GlobeIcon className="size-4" /> Add Domain
|
||||
@@ -173,13 +176,15 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
To access the application it is required to set at least 1
|
||||
domain
|
||||
</span>
|
||||
<div className="flex flex-row gap-4 flex-wrap">
|
||||
<AddDomain id={id} type={type}>
|
||||
<Button>
|
||||
<GlobeIcon className="size-4" /> Add Domain
|
||||
</Button>
|
||||
</AddDomain>
|
||||
</div>
|
||||
{canCreateDomain && (
|
||||
<div className="flex flex-row gap-4 flex-wrap">
|
||||
<AddDomain id={id} type={type}>
|
||||
<Button>
|
||||
<GlobeIcon className="size-4" /> Add Domain
|
||||
</Button>
|
||||
</AddDomain>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
|
||||
@@ -214,47 +219,51 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<AddDomain
|
||||
id={id}
|
||||
type={type}
|
||||
domainId={item.domainId}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10"
|
||||
{canCreateDomain && (
|
||||
<AddDomain
|
||||
id={id}
|
||||
type={type}
|
||||
domainId={item.domainId}
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
</AddDomain>
|
||||
<DialogAction
|
||||
title="Delete Domain"
|
||||
description="Are you sure you want to delete this domain?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteDomain({
|
||||
domainId: item.domainId,
|
||||
})
|
||||
.then((_data) => {
|
||||
refetch();
|
||||
toast.success(
|
||||
"Domain deleted successfully",
|
||||
);
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10"
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
</AddDomain>
|
||||
)}
|
||||
{canDeleteDomain && (
|
||||
<DialogAction
|
||||
title="Delete Domain"
|
||||
description="Are you sure you want to delete this domain?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteDomain({
|
||||
domainId: item.domainId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting domain");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
.then((_data) => {
|
||||
refetch();
|
||||
toast.success(
|
||||
"Domain deleted successfully",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting domain");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full break-all">
|
||||
|
||||
@@ -36,16 +36,19 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canWrite = permissions?.envVars.write ?? false;
|
||||
const queryMap = {
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
compose: () =>
|
||||
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
compose: () =>
|
||||
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
@@ -53,12 +56,13 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
||||
|
||||
const mutationMap = {
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
compose: () => api.compose.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
compose: () => api.compose.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
};
|
||||
const { mutateAsync, isPending } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
@@ -85,12 +89,13 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
|
||||
const onSubmit = async (formData: EnvironmentSchema) => {
|
||||
mutateAsync({
|
||||
composeId: id || "",
|
||||
libsqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
mysqlId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
composeId: id || "",
|
||||
env: formData.environment,
|
||||
})
|
||||
.then(async () => {
|
||||
@@ -185,25 +190,27 @@ PORT=3000
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{hasChanges && (
|
||||
{canWrite && (
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{hasChanges && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
isLoading={isPending}
|
||||
className="w-fit"
|
||||
type="submit"
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Cancel
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
className="w-fit"
|
||||
type="submit"
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
|
||||
@@ -31,6 +31,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canWrite = permissions?.envVars.write ?? false;
|
||||
const { mutateAsync, isPending } =
|
||||
api.application.saveEnvironment.useMutation();
|
||||
|
||||
@@ -201,27 +203,30 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={!canWrite}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{hasChanges && (
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
{canWrite && (
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{hasChanges && (
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
className="w-fit"
|
||||
type="submit"
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
className="w-fit"
|
||||
type="submit"
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -416,10 +416,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
@@ -228,10 +228,8 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[300px]">
|
||||
<p>
|
||||
|
||||
@@ -30,6 +30,9 @@ interface Props {
|
||||
|
||||
export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
const router = useRouter();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canDeploy = permissions?.deployment.create ?? false;
|
||||
const canUpdateService = permissions?.service.create ?? false;
|
||||
const { data, refetch } = api.application.one.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
@@ -57,128 +60,135 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
||||
<DialogAction
|
||||
title="Deploy Application"
|
||||
description="Are you sure you want to deploy this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await deploy({
|
||||
applicationId: applicationId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Application deployed successfully");
|
||||
refetch();
|
||||
router.push(
|
||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
|
||||
);
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Deploy Application"
|
||||
description="Are you sure you want to deploy this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await deploy({
|
||||
applicationId: applicationId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deploying application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
.then(() => {
|
||||
toast.success("Application deployed successfully");
|
||||
refetch();
|
||||
router.push(
|
||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deploying application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Downloads the source code and performs a complete build
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<DialogAction
|
||||
title="Reload Application"
|
||||
description="Are you sure you want to reload this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
applicationId: applicationId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Application reloaded successfully");
|
||||
refetch();
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Downloads the source code and performs a complete
|
||||
build
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Reload Application"
|
||||
description="Are you sure you want to reload this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
applicationId: applicationId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
.then(() => {
|
||||
toast.success("Application reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Reload the application without rebuilding it</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<DialogAction
|
||||
title="Rebuild Application"
|
||||
description="Are you sure you want to rebuild this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await redeploy({
|
||||
applicationId: applicationId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Application rebuilt successfully");
|
||||
refetch();
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Reload the application without rebuilding it</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Rebuild Application"
|
||||
description="Are you sure you want to rebuild this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await redeploy({
|
||||
applicationId: applicationId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error rebuilding application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
.then(() => {
|
||||
toast.success("Application rebuilt successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error rebuilding application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Hammer className="size-4 mr-1" />
|
||||
Rebuild
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Only rebuilds the application without downloading new
|
||||
code
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Hammer className="size-4 mr-1" />
|
||||
Rebuild
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Only rebuilds the application without downloading new
|
||||
code
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
{canDeploy && data?.applicationStatus === "idle" ? (
|
||||
<DialogAction
|
||||
title="Start Application"
|
||||
description="Are you sure you want to start this application?"
|
||||
@@ -219,7 +229,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
) : canDeploy ? (
|
||||
<DialogAction
|
||||
title="Stop Application"
|
||||
description="Are you sure you want to stop this application?"
|
||||
@@ -256,7 +266,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
) : null}
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
@@ -270,49 +280,53 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
Open Terminal
|
||||
</Button>
|
||||
</DockerTerminalModal>
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Autodeploy</span>
|
||||
<Switch
|
||||
aria-label="Toggle autodeploy"
|
||||
checked={data?.autoDeploy || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
applicationId,
|
||||
autoDeploy: enabled,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Auto Deploy Updated");
|
||||
await refetch();
|
||||
{canUpdateService && (
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Autodeploy</span>
|
||||
<Switch
|
||||
aria-label="Toggle autodeploy"
|
||||
checked={data?.autoDeploy || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
applicationId,
|
||||
autoDeploy: enabled,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Auto Deploy");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
.then(async () => {
|
||||
toast.success("Auto Deploy Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Auto Deploy");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Clean Cache</span>
|
||||
<Switch
|
||||
aria-label="Toggle clean cache"
|
||||
checked={data?.cleanCache || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
applicationId,
|
||||
cleanCache: enabled,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Clean Cache Updated");
|
||||
await refetch();
|
||||
{canUpdateService && (
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Clean Cache</span>
|
||||
<Switch
|
||||
aria-label="Toggle clean cache"
|
||||
checked={data?.cleanCache || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
applicationId,
|
||||
cleanCache: enabled,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Clean Cache");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
.then(async () => {
|
||||
toast.success("Clean Cache Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Clean Cache");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ShowProviderForm applicationId={applicationId} />
|
||||
|
||||
@@ -71,6 +71,7 @@ const formSchema = z
|
||||
"mongo",
|
||||
"mysql",
|
||||
"redis",
|
||||
"libsql",
|
||||
]),
|
||||
serviceName: z.string(),
|
||||
destinationId: z.string().min(1, "Destination required"),
|
||||
|
||||
@@ -46,6 +46,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export const DeleteService = ({ id, type }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canDelete = permissions?.service.delete ?? false;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const queryMap = {
|
||||
@@ -55,6 +57,7 @@ export const DeleteService = ({ id, type }: Props) => {
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
@@ -70,6 +73,7 @@ export const DeleteService = ({ id, type }: Props) => {
|
||||
redis: () => api.redis.remove.useMutation(),
|
||||
mysql: () => api.mysql.remove.useMutation(),
|
||||
mariadb: () => api.mariadb.remove.useMutation(),
|
||||
libsql: () => api.libsql.remove.useMutation(),
|
||||
application: () => api.application.delete.useMutation(),
|
||||
mongo: () => api.mongo.remove.useMutation(),
|
||||
compose: () => api.compose.delete.useMutation(),
|
||||
@@ -96,6 +100,7 @@ export const DeleteService = ({ id, type }: Props) => {
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
libsqlId: id || "",
|
||||
applicationId: id || "",
|
||||
composeId: id || "",
|
||||
deleteVolumes,
|
||||
@@ -123,6 +128,8 @@ export const DeleteService = ({ id, type }: Props) => {
|
||||
data?.applicationStatus === "running") ||
|
||||
(data && "composeStatus" in data && data?.composeStatus === "running");
|
||||
|
||||
if (!canDelete) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
|
||||
@@ -19,6 +19,9 @@ interface Props {
|
||||
}
|
||||
export const ComposeActions = ({ composeId }: Props) => {
|
||||
const router = useRouter();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canDeploy = permissions?.deployment.create ?? false;
|
||||
const canUpdateService = permissions?.service.create ?? false;
|
||||
const { data, refetch } = api.compose.one.useQuery(
|
||||
{
|
||||
composeId,
|
||||
@@ -35,162 +38,169 @@ export const ComposeActions = ({ composeId }: Props) => {
|
||||
return (
|
||||
<div className="flex flex-row gap-4 w-full flex-wrap ">
|
||||
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
||||
<DialogAction
|
||||
title="Deploy Compose"
|
||||
description="Are you sure you want to deploy this compose?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await deploy({
|
||||
composeId: composeId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Compose deployed successfully");
|
||||
refetch();
|
||||
router.push(
|
||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deploying compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.composeStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Downloads the source code and performs a complete build</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<DialogAction
|
||||
title="Reload Compose"
|
||||
description="Are you sure you want to reload this compose?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await redeploy({
|
||||
composeId: composeId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Compose reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={data?.composeStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Reload the compose without rebuilding it</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
{data?.composeType === "docker-compose" &&
|
||||
data?.composeStatus === "idle" ? (
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Start Compose"
|
||||
description="Are you sure you want to start this compose?"
|
||||
title="Deploy Compose"
|
||||
description="Are you sure you want to deploy this compose?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
await deploy({
|
||||
composeId: composeId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Compose started successfully");
|
||||
toast.success("Compose deployed successfully");
|
||||
refetch();
|
||||
router.push(
|
||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting compose");
|
||||
toast.error("Error deploying compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
variant="default"
|
||||
isLoading={data?.composeStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the compose (requires a previous successful build)
|
||||
Downloads the source code and performs a complete build
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
)}
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Stop Compose"
|
||||
description="Are you sure you want to stop this compose?"
|
||||
title="Reload Compose"
|
||||
description="Are you sure you want to reload this compose?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
await redeploy({
|
||||
composeId: composeId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Compose stopped successfully");
|
||||
toast.success("Compose reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping compose");
|
||||
toast.error("Error reloading compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
variant="secondary"
|
||||
isLoading={data?.composeStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running compose</p>
|
||||
<p>Reload the compose without rebuilding it</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy &&
|
||||
(data?.composeType === "docker-compose" &&
|
||||
data?.composeStatus === "idle" ? (
|
||||
<DialogAction
|
||||
title="Start Compose"
|
||||
description="Are you sure you want to start this compose?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
composeId: composeId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Compose started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the compose (requires a previous successful build)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop Compose"
|
||||
description="Are you sure you want to stop this compose?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
composeId: composeId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Compose stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running compose</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
@@ -205,27 +215,29 @@ export const ComposeActions = ({ composeId }: Props) => {
|
||||
Open Terminal
|
||||
</Button>
|
||||
</DockerTerminalModal>
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Autodeploy</span>
|
||||
<Switch
|
||||
aria-label="Toggle autodeploy"
|
||||
checked={data?.autoDeploy || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
composeId,
|
||||
autoDeploy: enabled,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Auto Deploy Updated");
|
||||
await refetch();
|
||||
{canUpdateService && (
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Autodeploy</span>
|
||||
<Switch
|
||||
aria-label="Toggle autodeploy"
|
||||
checked={data?.autoDeploy || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
composeId,
|
||||
autoDeploy: enabled,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Auto Deploy");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
.then(async () => {
|
||||
toast.success("Auto Deploy Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Auto Deploy");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,6 +26,8 @@ const AddComposeFile = z.object({
|
||||
type AddComposeFile = z.infer<typeof AddComposeFile>;
|
||||
|
||||
export const ComposeFileEditor = ({ composeId }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canUpdate = permissions?.service.create ?? false;
|
||||
const utils = api.useUtils();
|
||||
const { data, refetch } = api.compose.one.useQuery(
|
||||
{
|
||||
@@ -164,14 +166,16 @@ services:
|
||||
</Form>
|
||||
<div className="flex justify-between flex-col lg:flex-row gap-2">
|
||||
<div className="w-full flex flex-col lg:flex-row gap-4 items-end" />
|
||||
<Button
|
||||
type="submit"
|
||||
form="hook-form-save-compose-file"
|
||||
isLoading={isPending}
|
||||
className="lg:w-fit w-full"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
{canUpdate && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="hook-form-save-compose-file"
|
||||
isLoading={isPending}
|
||||
className="lg:w-fit w-full"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
@@ -230,10 +230,8 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[300px]">
|
||||
<p>
|
||||
|
||||
@@ -65,7 +65,13 @@ import { ScheduleFormField } from "../../application/schedules/handle-schedules"
|
||||
|
||||
type CacheType = "cache" | "fetch";
|
||||
|
||||
type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
|
||||
type DatabaseType =
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mysql"
|
||||
| "mongo"
|
||||
| "web-server"
|
||||
| "libsql";
|
||||
|
||||
const Schema = z
|
||||
.object({
|
||||
@@ -77,7 +83,7 @@ const Schema = z
|
||||
keepLatestCount: z.coerce.number().optional(),
|
||||
serviceName: z.string().nullable(),
|
||||
databaseType: z
|
||||
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
|
||||
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server", "libsql"])
|
||||
.optional(),
|
||||
backupType: z.enum(["database", "compose"]),
|
||||
metadata: z
|
||||
@@ -209,7 +215,12 @@ export const HandleBackup = ({
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
database: databaseType === "web-server" ? "dokploy" : "",
|
||||
database:
|
||||
databaseType === "web-server"
|
||||
? "dokploy"
|
||||
: databaseType === "libsql"
|
||||
? "iku.db"
|
||||
: "",
|
||||
destinationId: "",
|
||||
enabled: true,
|
||||
prefix: "/",
|
||||
@@ -246,7 +257,9 @@ export const HandleBackup = ({
|
||||
? backup?.database
|
||||
: databaseType === "web-server"
|
||||
? "dokploy"
|
||||
: "",
|
||||
: databaseType === "libsql"
|
||||
? "iku.db"
|
||||
: "",
|
||||
destinationId: backup?.destinationId ?? "",
|
||||
enabled: backup?.enabled ?? true,
|
||||
prefix: backup?.prefix ?? "/",
|
||||
@@ -281,11 +294,15 @@ export const HandleBackup = ({
|
||||
? {
|
||||
mongoId: id,
|
||||
}
|
||||
: databaseType === "web-server"
|
||||
: databaseType === "libsql"
|
||||
? {
|
||||
userId: id,
|
||||
libsqlId: id,
|
||||
}
|
||||
: undefined;
|
||||
: databaseType === "web-server"
|
||||
? {
|
||||
userId: id,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
await createBackup({
|
||||
destinationId: data.destinationId,
|
||||
@@ -568,7 +585,10 @@ export const HandleBackup = ({
|
||||
<FormLabel>Database</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={databaseType === "web-server"}
|
||||
disabled={
|
||||
databaseType === "web-server" ||
|
||||
databaseType === "libsql"
|
||||
}
|
||||
placeholder={"dokploy"}
|
||||
{...field}
|
||||
/>
|
||||
|
||||
@@ -88,7 +88,7 @@ const RestoreBackupSchema = z
|
||||
message: "Database name is required",
|
||||
}),
|
||||
databaseType: z
|
||||
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
|
||||
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server", "libsql"])
|
||||
.optional(),
|
||||
backupType: z.enum(["database", "compose"]).default("database"),
|
||||
metadata: z
|
||||
@@ -211,7 +211,12 @@ export const RestoreBackup = ({
|
||||
defaultValues: {
|
||||
destinationId: "",
|
||||
backupFile: "",
|
||||
databaseName: databaseType === "web-server" ? "dokploy" : "",
|
||||
databaseName:
|
||||
databaseType === "web-server"
|
||||
? "dokploy"
|
||||
: databaseType === "libsql"
|
||||
? "iku.db"
|
||||
: "",
|
||||
databaseType:
|
||||
backupType === "compose" ? ("postgres" as DatabaseType) : databaseType,
|
||||
backupType: backupType,
|
||||
@@ -523,7 +528,10 @@ export const RestoreBackup = ({
|
||||
<Input
|
||||
placeholder="Enter database name"
|
||||
{...field}
|
||||
disabled={databaseType === "web-server"}
|
||||
disabled={
|
||||
databaseType === "web-server" ||
|
||||
databaseType === "libsql"
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
@@ -53,14 +53,16 @@ export const ShowBackups = ({
|
||||
const queryMap =
|
||||
backupType === "database"
|
||||
? {
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
mysql: () =>
|
||||
api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
mongo: () =>
|
||||
api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
mysql: () =>
|
||||
api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
libsql: () =>
|
||||
api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
"web-server": () => api.user.getBackups.useQuery(),
|
||||
}
|
||||
: {
|
||||
@@ -77,10 +79,11 @@ export const ShowBackups = ({
|
||||
const mutationMap =
|
||||
backupType === "database"
|
||||
? {
|
||||
postgres: api.backup.manualBackupPostgres.useMutation(),
|
||||
mysql: api.backup.manualBackupMySql.useMutation(),
|
||||
mariadb: api.backup.manualBackupMariadb.useMutation(),
|
||||
mongo: api.backup.manualBackupMongo.useMutation(),
|
||||
mysql: api.backup.manualBackupMySql.useMutation(),
|
||||
postgres: api.backup.manualBackupPostgres.useMutation(),
|
||||
libsql: api.backup.manualBackupLibsql.useMutation(),
|
||||
"web-server": api.backup.manualBackupWebServer.useMutation(),
|
||||
}
|
||||
: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
containerId: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export const RemoveContainerDialog = ({ containerId, serverId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, isPending } = api.docker.removeContainer.useMutation();
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
Remove Container
|
||||
</DropdownMenuItem>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently remove the container{" "}
|
||||
<span className="font-semibold">{containerId}</span>. If the
|
||||
container is running, it will be forcefully stopped and removed.
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={isPending}
|
||||
onClick={async () => {
|
||||
await mutateAsync({ containerId, serverId })
|
||||
.then(async () => {
|
||||
toast.success("Container removed successfully");
|
||||
await utils.docker.getContainers.invalidate();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ShowContainerConfig } from "../config/show-container-config";
|
||||
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
|
||||
import { RemoveContainerDialog } from "../remove/remove-container";
|
||||
import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
|
||||
import type { Container } from "./show-containers";
|
||||
|
||||
@@ -127,6 +128,10 @@ export const columns: ColumnDef<Container>[] = [
|
||||
>
|
||||
Terminal
|
||||
</DockerTerminalModal>
|
||||
<RemoveContainerDialog
|
||||
containerId={container.containerId}
|
||||
serverId={container.serverId}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -45,10 +45,12 @@ import {
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
type User = typeof authClient.$Infer.Session.user;
|
||||
|
||||
export const ImpersonationBar = () => {
|
||||
const { config: whitelabeling } = useWhitelabeling();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [isImpersonating, setIsImpersonating] = useState(false);
|
||||
@@ -180,7 +182,10 @@ export const ImpersonationBar = () => {
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4 px-4 md:px-20 w-full">
|
||||
<Logo className="w-10 h-10" />
|
||||
<Logo
|
||||
className="w-10 h-10"
|
||||
logoUrl={whitelabeling?.logoUrl || undefined}
|
||||
/>
|
||||
{!isImpersonating ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const DockerProviderSchema = z.object({
|
||||
externalPort: z.preprocess((a) => {
|
||||
if (a === null || a === undefined || a === "") return null;
|
||||
const parsed = Number.parseInt(String(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}, z
|
||||
.number()
|
||||
.gte(0, "Range must be 0 - 65535")
|
||||
.lte(65535, "Range must be 0 - 65535")
|
||||
.nullable()),
|
||||
externalGRPCPort: z.preprocess((a) => {
|
||||
if (a === null || a === undefined || a === "") return null;
|
||||
const parsed = Number.parseInt(String(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}, z
|
||||
.number()
|
||||
.gte(0, "Range must be 0 - 65535")
|
||||
.lte(65535, "Range must be 0 - 65535")
|
||||
.nullable()),
|
||||
externalAdminPort: z.preprocess((a) => {
|
||||
if (a === null || a === undefined || a === "") return null;
|
||||
const parsed = Number.parseInt(String(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}, z
|
||||
.number()
|
||||
.gte(0, "Range must be 0 - 65535")
|
||||
.lte(65535, "Range must be 0 - 65535")
|
||||
.nullable()),
|
||||
});
|
||||
|
||||
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
||||
|
||||
interface Props {
|
||||
libsqlId: string;
|
||||
}
|
||||
export const ShowExternalLibsqlCredentials = ({ libsqlId }: Props) => {
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.libsql.one.useQuery({ libsqlId });
|
||||
const { mutateAsync, isPending } = api.libsql.saveExternalPorts.useMutation();
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
const [connectionGRPCUrl, setGRPCConnectionUrl] = useState("");
|
||||
const getIp = data?.server?.ipAddress || ip;
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(DockerProviderSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
externalPort: data.externalPort,
|
||||
externalGRPCPort: data.externalGRPCPort,
|
||||
externalAdminPort: data.externalAdminPort,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
|
||||
const onSubmit = async (values: DockerProvider) => {
|
||||
await mutateAsync({
|
||||
externalPort: values.externalPort,
|
||||
externalGRPCPort: values.externalGRPCPort,
|
||||
externalAdminPort: values.externalAdminPort,
|
||||
libsqlId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("External port/ports updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port/ports");
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
setConnectionUrl(
|
||||
`http://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`,
|
||||
);
|
||||
|
||||
if (data?.sqldNode !== "replica") {
|
||||
const grpcPort = form.watch("externalGRPCPort") || data?.externalGRPCPort;
|
||||
setGRPCConnectionUrl(
|
||||
`http://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${grpcPort}`,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
data?.externalGRPCPort,
|
||||
data?.databasePassword,
|
||||
form,
|
||||
data?.databaseUser,
|
||||
getIp,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable through the internet, you
|
||||
must set a port and ensure that the port is not being used by
|
||||
another application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
{!getIp && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link href="/dashboard/settings/server" className="text-primary">
|
||||
{data?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to fix the database url connection.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>External Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="8080"
|
||||
{...field}
|
||||
value={field.value as string}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!data?.externalPort && (
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>External Host</Label>
|
||||
<ToggleVisibilityInput value={connectionUrl} disabled />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalAdminPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>External Admin Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="5000"
|
||||
{...field}
|
||||
value={field.value as string}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data?.sqldNode !== "replica" && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalGRPCPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>External GRPC Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="5001"
|
||||
{...field}
|
||||
value={field.value as string}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!data?.externalGRPCPort && (
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>External GRPC Host</Label>
|
||||
<ToggleVisibilityInput
|
||||
value={connectionGRPCUrl}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,268 @@
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { DrawerLogs } from "@/components/shared/drawer-logs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { type LogLine, parseLogs } from "../../docker/logs/utils";
|
||||
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
|
||||
|
||||
interface Props {
|
||||
libsqlId: string;
|
||||
}
|
||||
|
||||
export const ShowGeneralLibsql = ({ libsqlId }: Props) => {
|
||||
const { data, refetch } = api.libsql.one.useQuery(
|
||||
{
|
||||
libsqlId,
|
||||
},
|
||||
{ enabled: !!libsqlId },
|
||||
);
|
||||
|
||||
const { mutateAsync: reload, isPending: isReloading } =
|
||||
api.libsql.reload.useMutation();
|
||||
|
||||
const { mutateAsync: start, isPending: isStarting } =
|
||||
api.libsql.start.useMutation();
|
||||
|
||||
const { mutateAsync: stop, isPending: isStopping } =
|
||||
api.libsql.stop.useMutation();
|
||||
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
api.libsql.deployWithLogs.useSubscription(
|
||||
{
|
||||
libsqlId: libsqlId,
|
||||
},
|
||||
{
|
||||
enabled: isDeploying,
|
||||
onData(log) {
|
||||
if (!isDrawerOpen) {
|
||||
setIsDrawerOpen(true);
|
||||
}
|
||||
|
||||
if (log === "Deployment completed successfully!") {
|
||||
setIsDeploying(false);
|
||||
}
|
||||
const parsedLogs = parseLogs(log);
|
||||
setFilteredLogs((prev) => [...prev, ...parsedLogs]);
|
||||
},
|
||||
onError(error) {
|
||||
console.error("Deployment logs error:", error);
|
||||
setIsDeploying(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Deploy Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Deploy Libsql"
|
||||
description="Are you sure you want to deploy this Libsql?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Downloads and sets up the Libsql database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Reload Libsql"
|
||||
description="Are you sure you want to reload this libsql?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
libsqlId: libsqlId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Libsql reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading Libsql");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Restart the Libsql service without rebuilding</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</TooltipProvider>
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Start Libsql"
|
||||
description="Are you sure you want to start this Libsql?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
libsqlId: libsqlId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Libsql started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting Libsql");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the Libsql database (requires a previous
|
||||
successful setup)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Stop Libsql"
|
||||
description="Are you sure you want to stop this Libsql?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
libsqlId: libsqlId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Libsql stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping Libsql");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running Libsql database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
serverId={data?.serverId || ""}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Terminal className="size-4 mr-1" />
|
||||
Open Terminal
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Open a terminal to the Libsql container</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DockerTerminalModal>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DrawerLogs
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={() => {
|
||||
setIsDrawerOpen(false);
|
||||
setFilteredLogs([]);
|
||||
setIsDeploying(false);
|
||||
refetch();
|
||||
}}
|
||||
filteredLogs={filteredLogs}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { SelectGroup } from "@radix-ui/react-select";
|
||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
libsqlId: string;
|
||||
}
|
||||
export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => {
|
||||
const { data } = api.libsql.one.useQuery({ libsqlId });
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Internal Credentials</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-row gap-4">
|
||||
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>User</Label>
|
||||
<Input disabled value={data?.databaseUser} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Sqld Node</Label>
|
||||
<Select value={data?.sqldNode} disabled>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Node type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["primary", "replica"].map((node) => (
|
||||
<SelectItem key={node} value={node}>
|
||||
{node.charAt(0).toUpperCase() + node.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Password</Label>
|
||||
<div className="flex flex-row gap-4">
|
||||
<ToggleVisibilityInput
|
||||
disabled
|
||||
value={data?.databasePassword}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<Label>Internal Port (Container)</Label>
|
||||
<Input disabled value="8080" />
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<Label>Internal GRPC Port (Container)</Label>
|
||||
<Input disabled value="5001" />
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<Label>Internal Admin Port (Container)</Label>
|
||||
<Input disabled value="5000" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Host</Label>
|
||||
<Input disabled value={data?.appName} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Enable Namespaces</Label>
|
||||
<Select
|
||||
disabled
|
||||
defaultValue={
|
||||
data?.enableNamespaces
|
||||
? String(data?.enableNamespaces)
|
||||
: "false"
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={"false"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{["false", "true"].map((node) => (
|
||||
<SelectItem key={node} value={node}>
|
||||
{node.charAt(0).toUpperCase() + node.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 md:col-span-2">
|
||||
<Label>Internal Connection URL </Label>
|
||||
<ToggleVisibilityInput
|
||||
disabled
|
||||
value={`http://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:8080`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:col-span-2">
|
||||
<Label>Internal Replication Connection URL </Label>
|
||||
<ToggleVisibilityInput
|
||||
disabled
|
||||
value={`http://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:5001`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
163
apps/dokploy/components/dashboard/libsql/update-libsql.tsx
Normal file
163
apps/dokploy/components/dashboard/libsql/update-libsql.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const updateLibsqlSchema = z.object({
|
||||
name: z.string().min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
type UpdateLibsql = z.infer<typeof updateLibsqlSchema>;
|
||||
|
||||
interface Props {
|
||||
libsqlId: string;
|
||||
}
|
||||
|
||||
export const UpdateLibsql = ({ libsqlId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, error, isError, isPending } =
|
||||
api.libsql.update.useMutation();
|
||||
const { data } = api.libsql.one.useQuery(
|
||||
{
|
||||
libsqlId,
|
||||
},
|
||||
{
|
||||
enabled: !!libsqlId,
|
||||
},
|
||||
);
|
||||
const form = useForm<UpdateLibsql>({
|
||||
defaultValues: {
|
||||
description: data?.description ?? "",
|
||||
name: data?.name ?? "",
|
||||
},
|
||||
resolver: zodResolver(updateLibsqlSchema),
|
||||
});
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
description: data.description ?? "",
|
||||
name: data.name,
|
||||
});
|
||||
}
|
||||
}, [data, form, form.reset]);
|
||||
|
||||
const onSubmit = async (formData: UpdateLibsql) => {
|
||||
await mutateAsync({
|
||||
name: formData.name,
|
||||
libsqlId: libsqlId,
|
||||
description: formData.description || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Libsql updated successfully");
|
||||
utils.libsql.one.invalidate({
|
||||
libsqlId: libsqlId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating the Libsql");
|
||||
})
|
||||
.finally(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10 "
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modify Libsql</DialogTitle>
|
||||
<DialogDescription>Update the Libsql data</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="grid items-center gap-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="hook-form-update-libsql"
|
||||
className="grid w-full gap-4 "
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Vandelay Industries" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Description about your project..."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
form="hook-form-update-libsql"
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -21,6 +21,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canDeploy = permissions?.deployment.create ?? false;
|
||||
const { data, refetch } = api.mariadb.one.useQuery(
|
||||
{
|
||||
mariadbId,
|
||||
@@ -72,154 +74,33 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
|
||||
<CardTitle className="text-xl">Deploy Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Deploy Mariadb"
|
||||
description="Are you sure you want to deploy this mariadb?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Downloads and sets up the MariaDB database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Reload Mariadb"
|
||||
description="Are you sure you want to reload this mariadb?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
mariadbId: mariadbId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mariadb reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading Mariadb");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Restart the MariaDB service without rebuilding</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</TooltipProvider>
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
{canDeploy && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Start Mariadb"
|
||||
description="Are you sure you want to start this mariadb?"
|
||||
title="Deploy Mariadb"
|
||||
description="Are you sure you want to deploy this mariadb?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
mariadbId: mariadbId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mariadb started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting Mariadb");
|
||||
});
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the MariaDB database (requires a previous
|
||||
successful setup)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Stop Mariadb"
|
||||
description="Are you sure you want to stop this mariadb?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
mariadbId: mariadbId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mariadb stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping Mariadb");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running MariaDB database</p>
|
||||
<p>Downloads and sets up the MariaDB database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
@@ -227,6 +108,132 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
|
||||
</DialogAction>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{canDeploy && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Reload Mariadb"
|
||||
description="Are you sure you want to reload this mariadb?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
mariadbId: mariadbId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mariadb reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading Mariadb");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Restart the MariaDB service without rebuilding</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{canDeploy &&
|
||||
(data?.applicationStatus === "idle" ? (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Start Mariadb"
|
||||
description="Are you sure you want to start this mariadb?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
mariadbId: mariadbId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mariadb started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting Mariadb");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the MariaDB database (requires a previous
|
||||
successful setup)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Stop Mariadb"
|
||||
description="Are you sure you want to stop this mariadb?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
mariadbId: mariadbId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mariadb stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping Mariadb");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running MariaDB database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</TooltipProvider>
|
||||
))}
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
serverId={data?.serverId || ""}
|
||||
|
||||
@@ -21,6 +21,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowGeneralMongo = ({ mongoId }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canDeploy = permissions?.deployment.create ?? false;
|
||||
const { data, refetch } = api.mongo.one.useQuery(
|
||||
{
|
||||
mongoId,
|
||||
@@ -73,153 +75,158 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Deploy Mongo"
|
||||
description="Are you sure you want to deploy this mongo?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Downloads and sets up the MongoDB database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<DialogAction
|
||||
title="Reload Mongo"
|
||||
description="Are you sure you want to reload this mongo?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
mongoId: mongoId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mongo reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading Mongo");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Restart the MongoDB service without rebuilding</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Start Mongo"
|
||||
description="Are you sure you want to start this mongo?"
|
||||
title="Deploy Mongo"
|
||||
description="Are you sure you want to deploy this mongo?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
mongoId: mongoId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mongo started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting Mongo");
|
||||
});
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the MongoDB database (requires a previous
|
||||
successful setup)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop Mongo"
|
||||
description="Are you sure you want to stop this mongo?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
mongoId: mongoId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mongo stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping Mongo");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running MongoDB database</p>
|
||||
<p>Downloads and sets up the MongoDB database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Reload Mongo"
|
||||
description="Are you sure you want to reload this mongo?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
mongoId: mongoId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mongo reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading Mongo");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Restart the MongoDB service without rebuilding</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy &&
|
||||
(data?.applicationStatus === "idle" ? (
|
||||
<DialogAction
|
||||
title="Start Mongo"
|
||||
description="Are you sure you want to start this mongo?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
mongoId: mongoId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mongo started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting Mongo");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the MongoDB database (requires a previous
|
||||
successful setup)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop Mongo"
|
||||
description="Are you sure you want to stop this mongo?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
mongoId: mongoId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mongo stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping Mongo");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running MongoDB database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
|
||||
@@ -21,6 +21,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowGeneralMysql = ({ mysqlId }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canDeploy = permissions?.deployment.create ?? false;
|
||||
const { data, refetch } = api.mysql.one.useQuery(
|
||||
{
|
||||
mysqlId,
|
||||
@@ -71,153 +73,158 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Deploy MySQL"
|
||||
description="Are you sure you want to deploy this mysql?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Downloads and sets up the MySQL database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<DialogAction
|
||||
title="Reload MySQL"
|
||||
description="Are you sure you want to reload this mysql?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
mysqlId: mysqlId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("MySQL reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading MySQL");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Restart the MySQL service without rebuilding</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Start MySQL"
|
||||
description="Are you sure you want to start this mysql?"
|
||||
title="Deploy MySQL"
|
||||
description="Are you sure you want to deploy this mysql?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
mysqlId: mysqlId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("MySQL started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting MySQL");
|
||||
});
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the MySQL database (requires a previous
|
||||
successful setup)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop MySQL"
|
||||
description="Are you sure you want to stop this mysql?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
mysqlId: mysqlId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("MySQL stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping MySQL");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running MySQL database</p>
|
||||
<p>Downloads and sets up the MySQL database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Reload MySQL"
|
||||
description="Are you sure you want to reload this mysql?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
mysqlId: mysqlId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("MySQL reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading MySQL");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Restart the MySQL service without rebuilding</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy &&
|
||||
(data?.applicationStatus === "idle" ? (
|
||||
<DialogAction
|
||||
title="Start MySQL"
|
||||
description="Are you sure you want to start this mysql?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
mysqlId: mysqlId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("MySQL started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting MySQL");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the MySQL database (requires a previous
|
||||
successful setup)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop MySQL"
|
||||
description="Are you sure you want to stop this mysql?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
mysqlId: mysqlId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("MySQL stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping MySQL");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running MySQL database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
@@ -42,6 +42,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
@@ -56,6 +57,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
libsql: () => api.libsql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
@@ -84,7 +86,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
||||
form.reset({
|
||||
dockerImage: data.dockerImage,
|
||||
command: data.command || "",
|
||||
args: data.args?.map((arg) => ({ value: arg })) || [],
|
||||
args: (data as any).args?.map((arg: string) => ({ value: arg })) || [],
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
@@ -95,6 +97,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
libsqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
dockerImage: formData?.dockerImage,
|
||||
command: formData?.command,
|
||||
@@ -144,7 +147,14 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Command</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/bin/sh" {...field} />
|
||||
<Input
|
||||
placeholder={
|
||||
type === "libsql"
|
||||
? "sqld --db-path iku.db --http-listen-addr 0.0.0.0:8080 --grpc-listen-addr 0.0.0.0:5001 --admin-listen-addr 0.0.0.0:5000"
|
||||
: "Custom command"
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
@@ -21,6 +21,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowGeneralPostgres = ({ postgresId }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canDeploy = permissions?.deployment.create ?? false;
|
||||
const { data, refetch } = api.postgres.one.useQuery(
|
||||
{
|
||||
postgresId: postgresId,
|
||||
@@ -73,153 +75,162 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||
<TooltipProvider disableHoverableContent={false}>
|
||||
<DialogAction
|
||||
title="Deploy PostgreSQL"
|
||||
description="Are you sure you want to deploy this postgres?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Downloads and sets up the PostgreSQL database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<DialogAction
|
||||
title="Reload PostgreSQL"
|
||||
description="Are you sure you want to reload this postgres?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
postgresId: postgresId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("PostgreSQL reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading PostgreSQL");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Restart the PostgreSQL service without rebuilding</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Start PostgreSQL"
|
||||
description="Are you sure you want to start this postgres?"
|
||||
title="Deploy PostgreSQL"
|
||||
description="Are you sure you want to deploy this postgres?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
postgresId: postgresId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("PostgreSQL started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting PostgreSQL");
|
||||
});
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the PostgreSQL database (requires a previous
|
||||
successful setup)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop PostgreSQL"
|
||||
description="Are you sure you want to stop this postgres?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
postgresId: postgresId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("PostgreSQL stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping PostgreSQL");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running PostgreSQL database</p>
|
||||
<p>Downloads and sets up the PostgreSQL database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Reload PostgreSQL"
|
||||
description="Are you sure you want to reload this postgres?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
postgresId: postgresId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("PostgreSQL reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading PostgreSQL");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Restart the PostgreSQL service without rebuilding
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy &&
|
||||
(data?.applicationStatus === "idle" ? (
|
||||
<DialogAction
|
||||
title="Start PostgreSQL"
|
||||
description="Are you sure you want to start this postgres?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
postgresId: postgresId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("PostgreSQL started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting PostgreSQL");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the PostgreSQL database (requires a previous
|
||||
successful setup)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop PostgreSQL"
|
||||
description="Are you sure you want to stop this postgres?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
postgresId: postgresId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("PostgreSQL stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping PostgreSQL");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Stop the currently running PostgreSQL database
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
|
||||
@@ -79,7 +79,7 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
api.compose.create.useMutation();
|
||||
|
||||
// Get environment data to extract projectId
|
||||
const { data: environment } = api.environment.one.useQuery({ environmentId });
|
||||
// const { data: environment } = api.environment.one.useQuery({ environmentId });
|
||||
|
||||
const hasServers = servers && servers.length > 0;
|
||||
// Show dropdown logic based on cloud environment
|
||||
@@ -117,6 +117,8 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
await utils.environment.one.invalidate({
|
||||
environmentId,
|
||||
});
|
||||
// Invalidate the project query to refresh the project data for the advance-breadcrumb
|
||||
await utils.project.all.invalidate();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error creating the compose");
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
LibsqlIcon,
|
||||
MariadbIcon,
|
||||
MongodbIcon,
|
||||
MysqlIcon,
|
||||
@@ -55,6 +56,7 @@ import { api } from "@/utils/api";
|
||||
type DbType = z.infer<typeof mySchema>["type"];
|
||||
|
||||
const dockerImageDefaultPlaceholder: Record<DbType, string> = {
|
||||
libsql: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
|
||||
mongo: "mongo:7",
|
||||
mariadb: "mariadb:11",
|
||||
mysql: "mysql:8",
|
||||
@@ -66,8 +68,9 @@ const databasesUserDefaultPlaceholder: Record<
|
||||
Exclude<DbType, "redis">,
|
||||
string
|
||||
> = {
|
||||
mongo: "mongo",
|
||||
libsql: "libsql",
|
||||
mariadb: "mariadb",
|
||||
mongo: "mongo",
|
||||
mysql: "mysql",
|
||||
postgres: "postgres",
|
||||
};
|
||||
@@ -94,56 +97,88 @@ const baseDatabaseSchema = z.object({
|
||||
serverId: z.string().nullable(),
|
||||
});
|
||||
|
||||
const mySchema = z.discriminatedUnion("type", [
|
||||
z
|
||||
.object({
|
||||
type: z.literal("postgres"),
|
||||
databaseName: z.string().default("postgres"),
|
||||
databaseUser: z.string().default("postgres"),
|
||||
})
|
||||
.merge(baseDatabaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("mongo"),
|
||||
databaseUser: z.string().default("mongo"),
|
||||
replicaSets: z.boolean().default(false),
|
||||
})
|
||||
.merge(baseDatabaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("redis"),
|
||||
})
|
||||
.merge(baseDatabaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("mysql"),
|
||||
databaseRootPassword: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
|
||||
const mySchema = z
|
||||
.discriminatedUnion("type", [
|
||||
z
|
||||
.object({
|
||||
type: z.literal("libsql"),
|
||||
dockerImage: z
|
||||
.string()
|
||||
.default("ghcr.io/tursodatabase/libsql-server:v0.24.32"),
|
||||
databaseUser: z.string().default("libsql"),
|
||||
sqldNode: z.enum(["primary", "replica"]).default("primary"),
|
||||
sqldPrimaryUrl: z.string().optional(),
|
||||
enableNamespaces: z.boolean().default(false),
|
||||
})
|
||||
.merge(baseDatabaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("mariadb"),
|
||||
dockerImage: z.string().default("mariadb:4"),
|
||||
databaseRootPassword: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
|
||||
message:
|
||||
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
|
||||
})
|
||||
.optional(),
|
||||
databaseUser: z.string().default("mariadb"),
|
||||
databaseName: z.string().default("mariadb"),
|
||||
})
|
||||
.merge(baseDatabaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("mongo"),
|
||||
databaseUser: z.string().default("mongo"),
|
||||
replicaSets: z.boolean().default(false),
|
||||
})
|
||||
.merge(baseDatabaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("mysql"),
|
||||
databaseRootPassword: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
|
||||
message:
|
||||
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
|
||||
})
|
||||
.optional(),
|
||||
databaseUser: z.string().default("mysql"),
|
||||
databaseName: z.string().default("mysql"),
|
||||
})
|
||||
.merge(baseDatabaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("postgres"),
|
||||
databaseName: z.string().default("postgres"),
|
||||
databaseUser: z.string().default("postgres"),
|
||||
})
|
||||
.merge(baseDatabaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("redis"),
|
||||
})
|
||||
.merge(baseDatabaseSchema),
|
||||
])
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.type === "libsql") {
|
||||
if (data.sqldNode === "replica" && !data.sqldPrimaryUrl) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["sqldPrimaryUrl"],
|
||||
message: "sqldPrimaryUrl is required when sqldNode is 'replica'.",
|
||||
});
|
||||
}
|
||||
if (data.sqldNode !== "replica" && data.sqldPrimaryUrl) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["sqldPrimaryUrl"],
|
||||
message:
|
||||
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
|
||||
})
|
||||
.optional(),
|
||||
databaseUser: z.string().default("mysql"),
|
||||
databaseName: z.string().default("mysql"),
|
||||
})
|
||||
.merge(baseDatabaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("mariadb"),
|
||||
dockerImage: z.string().default("mariadb:4"),
|
||||
databaseRootPassword: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
|
||||
message:
|
||||
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
|
||||
})
|
||||
.optional(),
|
||||
databaseUser: z.string().default("mariadb"),
|
||||
databaseName: z.string().default("mariadb"),
|
||||
})
|
||||
.merge(baseDatabaseSchema),
|
||||
]);
|
||||
"sqldPrimaryUrl should not be provided when sqldNode is not 'replica'.",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const databasesMap = {
|
||||
postgres: {
|
||||
@@ -166,6 +201,10 @@ const databasesMap = {
|
||||
icon: <RedisIcon />,
|
||||
label: "Redis",
|
||||
},
|
||||
libsql: {
|
||||
icon: <LibsqlIcon className="size-10" />,
|
||||
label: "libSQL",
|
||||
},
|
||||
};
|
||||
|
||||
type AddDatabase = z.infer<typeof mySchema>;
|
||||
@@ -181,11 +220,12 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
const slug = slugify(projectName);
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
const postgresMutation = api.postgres.create.useMutation();
|
||||
const mongoMutation = api.mongo.create.useMutation();
|
||||
const redisMutation = api.redis.create.useMutation();
|
||||
const libsqlMutation = api.libsql.create.useMutation();
|
||||
const mariadbMutation = api.mariadb.create.useMutation();
|
||||
const mongoMutation = api.mongo.create.useMutation();
|
||||
const mysqlMutation = api.mysql.create.useMutation();
|
||||
const postgresMutation = api.postgres.create.useMutation();
|
||||
const redisMutation = api.redis.create.useMutation();
|
||||
|
||||
// Get environment data to extract projectId
|
||||
const { data: environment } = api.environment.one.useQuery({ environmentId });
|
||||
@@ -210,13 +250,15 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
},
|
||||
resolver: zodResolver(mySchema),
|
||||
});
|
||||
const sqldNode = form.watch("sqldNode");
|
||||
const type = form.watch("type");
|
||||
const activeMutation = {
|
||||
postgres: postgresMutation,
|
||||
mongo: mongoMutation,
|
||||
redis: redisMutation,
|
||||
libsql: libsqlMutation,
|
||||
mariadb: mariadbMutation,
|
||||
mongo: mongoMutation,
|
||||
mysql: mysqlMutation,
|
||||
postgres: postgresMutation,
|
||||
redis: redisMutation,
|
||||
};
|
||||
|
||||
const onSubmit = async (data: AddDatabase) => {
|
||||
@@ -233,12 +275,23 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
description: data.description,
|
||||
};
|
||||
|
||||
if (data.type === "postgres") {
|
||||
promise = postgresMutation.mutateAsync({
|
||||
if (data.type === "libsql") {
|
||||
promise = libsqlMutation.mutateAsync({
|
||||
...commonParams,
|
||||
sqldNode: data.sqldNode,
|
||||
sqldPrimaryUrl: data.sqldPrimaryUrl ?? null,
|
||||
enableNamespaces: data.enableNamespaces,
|
||||
databasePassword: data.databasePassword,
|
||||
databaseUser:
|
||||
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
});
|
||||
} else if (data.type === "mariadb") {
|
||||
promise = mariadbMutation.mutateAsync({
|
||||
...commonParams,
|
||||
databasePassword: data.databasePassword,
|
||||
databaseName: data.databaseName || "postgres",
|
||||
|
||||
databaseRootPassword: data.databaseRootPassword || "",
|
||||
databaseName: data.databaseName || "mariadb",
|
||||
databaseUser:
|
||||
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
@@ -252,22 +305,6 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
replicaSets: data.replicaSets,
|
||||
});
|
||||
} else if (data.type === "redis") {
|
||||
promise = redisMutation.mutateAsync({
|
||||
...commonParams,
|
||||
databasePassword: data.databasePassword,
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
});
|
||||
} else if (data.type === "mariadb") {
|
||||
promise = mariadbMutation.mutateAsync({
|
||||
...commonParams,
|
||||
databasePassword: data.databasePassword,
|
||||
databaseRootPassword: data.databaseRootPassword || "",
|
||||
databaseName: data.databaseName || "mariadb",
|
||||
databaseUser:
|
||||
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
});
|
||||
} else if (data.type === "mysql") {
|
||||
promise = mysqlMutation.mutateAsync({
|
||||
...commonParams,
|
||||
@@ -278,6 +315,21 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
databaseRootPassword: data.databaseRootPassword || "",
|
||||
});
|
||||
} else if (data.type === "postgres") {
|
||||
promise = postgresMutation.mutateAsync({
|
||||
...commonParams,
|
||||
databasePassword: data.databasePassword,
|
||||
databaseName: data.databaseName || "postgres",
|
||||
databaseUser:
|
||||
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
});
|
||||
} else if (data.type === "redis") {
|
||||
promise = redisMutation.mutateAsync({
|
||||
...commonParams,
|
||||
databasePassword: data.databasePassword,
|
||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||
});
|
||||
}
|
||||
|
||||
if (promise) {
|
||||
@@ -305,6 +357,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={visible} onOpenChange={setVisible}>
|
||||
<DialogTrigger className="w-full">
|
||||
@@ -506,8 +559,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{(type === "mysql" ||
|
||||
type === "mariadb" ||
|
||||
{(type === "mariadb" ||
|
||||
type === "mysql" ||
|
||||
type === "postgres") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -524,10 +577,101 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{(type === "mysql" ||
|
||||
|
||||
{type === "libsql" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sqldNode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Sqld Node</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value || "primary"}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={"primary"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{["primary", "replica"].map((node) => (
|
||||
<SelectItem key={node} value={node}>
|
||||
{node.charAt(0).toUpperCase() + node.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{type === "libsql" && sqldNode === "replica" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sqldPrimaryUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Sqld Primary URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"https://<host>:<port>"}
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{type === "libsql" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableNamespaces"
|
||||
render={({ field }) => {
|
||||
console.log(field.value);
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Enable Namespaces</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
field.onChange(Boolean(value))
|
||||
}
|
||||
defaultValue={
|
||||
field.value ? String(field.value) : "false"
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={"false"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{["false", "true"].map((node) => (
|
||||
<SelectItem key={node} value={node}>
|
||||
{node.charAt(0).toUpperCase() +
|
||||
node.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(type === "libsql" ||
|
||||
type === "mariadb" ||
|
||||
type === "postgres" ||
|
||||
type === "mongo") && (
|
||||
type === "mongo" ||
|
||||
type === "mysql" ||
|
||||
type === "postgres") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="databaseUser"
|
||||
@@ -568,7 +712,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{(type === "mysql" || type === "mariadb") && (
|
||||
{(type === "mariadb" || type === "mysql") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="databaseRootPassword"
|
||||
|
||||
@@ -332,6 +332,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
viewMode === "detailed" && "border-b",
|
||||
)}
|
||||
>
|
||||
{/** biome-ignore lint/performance/noImgElement: this is a valid use for img tag */}
|
||||
<img
|
||||
src={`${customBaseUrl || "https://templates.dokploy.com/"}/blueprints/${template?.id}/${template?.logo}`}
|
||||
className={cn(
|
||||
|
||||
@@ -57,19 +57,13 @@ export const AdvancedEnvironmentSelector = ({
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
// Get current user's permissions
|
||||
const { data: currentUser } = api.user.get.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
|
||||
// Check if user can create environments
|
||||
const canCreateEnvironments =
|
||||
currentUser?.role === "owner" ||
|
||||
currentUser?.role === "admin" ||
|
||||
currentUser?.canCreateEnvironments === true;
|
||||
const canCreateEnvironments = !!permissions?.environment.create;
|
||||
|
||||
// Check if user can delete environments
|
||||
const canDeleteEnvironments =
|
||||
currentUser?.role === "owner" ||
|
||||
currentUser?.role === "admin" ||
|
||||
currentUser?.canDeleteEnvironments === true;
|
||||
const canDeleteEnvironments = !!permissions?.environment.delete;
|
||||
|
||||
const haveServices =
|
||||
selectedEnvironment &&
|
||||
@@ -98,6 +92,8 @@ export const AdvancedEnvironmentSelector = ({
|
||||
|
||||
toast.success("Environment created successfully");
|
||||
utils.environment.byProjectId.invalidate({ projectId });
|
||||
// Invalidate the project query to refresh the project data for the advance-breadcrumb
|
||||
utils.project.all.invalidate();
|
||||
setIsCreateDialogOpen(false);
|
||||
setName("");
|
||||
setDescription("");
|
||||
|
||||
@@ -25,17 +25,17 @@ import {
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export type Services = {
|
||||
appName: string;
|
||||
serverId?: string | null;
|
||||
name: string;
|
||||
type:
|
||||
| "mariadb"
|
||||
| "application"
|
||||
| "postgres"
|
||||
| "mysql"
|
||||
| "compose"
|
||||
| "libsql"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "compose";
|
||||
| "mysql"
|
||||
| "postgres"
|
||||
| "redis";
|
||||
description?: string | null;
|
||||
id: string;
|
||||
createdAt: string;
|
||||
|
||||
@@ -39,6 +39,9 @@ interface Props {
|
||||
}
|
||||
|
||||
export const EnvironmentVariables = ({ environmentId, children }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canRead = permissions?.environmentEnvVars.read ?? false;
|
||||
const canWrite = permissions?.environmentEnvVars.write ?? false;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, error, isError, isPending } =
|
||||
@@ -97,6 +100,10 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
|
||||
};
|
||||
}, [form, onSubmit, isPending, isOpen]);
|
||||
|
||||
if (!canRead) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -141,6 +148,7 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
|
||||
<CodeEditor
|
||||
lineWrapping
|
||||
language="properties"
|
||||
readOnly={!canWrite}
|
||||
wrapperClassName="h-[35rem] font-mono"
|
||||
placeholder={`NODE_ENV=development
|
||||
DATABASE_URL=postgresql://localhost:5432/mydb
|
||||
@@ -157,11 +165,13 @@ API_KEY=your-api-key-here
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button isLoading={isPending} type="submit">
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
{canWrite && (
|
||||
<DialogFooter>
|
||||
<Button isLoading={isPending} type="submit">
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { TagSelector } from "@/components/shared/tag-selector";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -62,6 +63,7 @@ interface Props {
|
||||
export const HandleProject = ({ projectId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||
|
||||
const { mutateAsync, error, isError } = projectId
|
||||
? api.project.update.useMutation()
|
||||
@@ -75,6 +77,10 @@ export const HandleProject = ({ projectId }: Props) => {
|
||||
enabled: !!projectId,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: availableTags = [] } = api.tag.all.useQuery();
|
||||
const bulkAssignMutation = api.tag.bulkAssign.useMutation();
|
||||
|
||||
const router = useRouter();
|
||||
const form = useForm<AddProject>({
|
||||
defaultValues: {
|
||||
@@ -89,6 +95,13 @@ export const HandleProject = ({ projectId }: Props) => {
|
||||
description: data?.description ?? "",
|
||||
name: data?.name ?? "",
|
||||
});
|
||||
// Load existing tags when editing a project
|
||||
if (data?.projectTags) {
|
||||
const tagIds = data.projectTags.map((pt) => pt.tagId);
|
||||
setSelectedTagIds(tagIds);
|
||||
} else {
|
||||
setSelectedTagIds([]);
|
||||
}
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
||||
|
||||
const onSubmit = async (data: AddProject) => {
|
||||
@@ -98,12 +111,26 @@ export const HandleProject = ({ projectId }: Props) => {
|
||||
projectId: projectId || "",
|
||||
})
|
||||
.then(async (data) => {
|
||||
// Assign tags to the project (both create and update)
|
||||
const projectIdToUse =
|
||||
projectId ||
|
||||
(data && "project" in data ? data.project.projectId : undefined);
|
||||
|
||||
if (projectIdToUse) {
|
||||
try {
|
||||
await bulkAssignMutation.mutateAsync({
|
||||
projectId: projectIdToUse,
|
||||
tagIds: selectedTagIds,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Failed to assign tags to project");
|
||||
}
|
||||
}
|
||||
|
||||
await utils.project.all.invalidate();
|
||||
toast.success(projectId ? "Project Updated" : "Project Created");
|
||||
setIsOpen(false);
|
||||
if (!projectId) {
|
||||
const projectIdToUse =
|
||||
data && "project" in data ? data.project.projectId : undefined;
|
||||
const environmentIdToUse =
|
||||
data && "environment" in data
|
||||
? data.environment.environmentId
|
||||
@@ -190,6 +217,20 @@ export const HandleProject = ({ projectId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>Tags</FormLabel>
|
||||
<TagSelector
|
||||
tags={availableTags.map((tag) => ({
|
||||
id: tag.tagId,
|
||||
name: tag.name,
|
||||
color: tag.color ?? undefined,
|
||||
}))}
|
||||
selectedTags={selectedTagIds}
|
||||
onTagsChange={setSelectedTagIds}
|
||||
placeholder="Select tags..."
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -39,6 +39,9 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ProjectEnvironment = ({ projectId, children }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canRead = permissions?.projectEnvVars.read ?? false;
|
||||
const canWrite = permissions?.projectEnvVars.write ?? false;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, error, isError, isPending } =
|
||||
@@ -96,6 +99,10 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
|
||||
};
|
||||
}, [form, onSubmit, isPending, isOpen]);
|
||||
|
||||
if (!canRead) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -139,6 +146,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
|
||||
<CodeEditor
|
||||
lineWrapping
|
||||
language="properties"
|
||||
readOnly={!canWrite}
|
||||
wrapperClassName="h-[35rem] font-mono"
|
||||
placeholder={`NODE_ENV=production
|
||||
PORT=3000
|
||||
@@ -154,11 +162,13 @@ PORT=3000
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button isLoading={isPending} type="submit">
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
{canWrite && (
|
||||
<DialogFooter>
|
||||
<Button isLoading={isPending} type="submit">
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
AlertTriangle,
|
||||
ArrowUpDown,
|
||||
BookIcon,
|
||||
ExternalLinkIcon,
|
||||
FolderInput,
|
||||
Loader2,
|
||||
MoreHorizontalIcon,
|
||||
@@ -16,7 +15,8 @@ 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 { TagBadge } from "@/components/shared/tag-badge";
|
||||
import { TagFilter } from "@/components/shared/tag-filter";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -40,10 +40,8 @@ import {
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
@@ -53,7 +51,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { TimeBadge } from "@/components/ui/time-badge";
|
||||
import { api } from "@/utils/api";
|
||||
import { useDebounce } from "@/utils/hooks/use-debounce";
|
||||
import { HandleProject } from "./handle-project";
|
||||
@@ -65,7 +62,9 @@ export const ShowProjects = () => {
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data, isPending } = api.project.all.useQuery();
|
||||
const { data: auth } = api.user.get.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const { mutateAsync } = api.project.remove.useMutation();
|
||||
const { data: availableTags } = api.tag.all.useQuery();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(
|
||||
router.isReady && typeof router.query.q === "string" ? router.query.q : "",
|
||||
@@ -79,10 +78,31 @@ export const ShowProjects = () => {
|
||||
return "createdAt-desc";
|
||||
});
|
||||
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem("projectsTagFilter");
|
||||
return saved ? JSON.parse(saved) : [];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("projectsSort", sortBy);
|
||||
}, [sortBy]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("projectsTagFilter", JSON.stringify(selectedTagIds));
|
||||
}, [selectedTagIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!availableTags) return;
|
||||
const validIds = new Set(availableTags.map((t) => t.tagId));
|
||||
setSelectedTagIds((prev) => {
|
||||
const filtered = prev.filter((id) => validIds.has(id));
|
||||
return filtered.length === prev.length ? prev : filtered;
|
||||
});
|
||||
}, [availableTags]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return;
|
||||
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
|
||||
@@ -110,7 +130,7 @@ export const ShowProjects = () => {
|
||||
const filteredProjects = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
const filtered = data.filter(
|
||||
let filtered = data.filter(
|
||||
(project) =>
|
||||
project.name
|
||||
.toLowerCase()
|
||||
@@ -120,6 +140,15 @@ export const ShowProjects = () => {
|
||||
.includes(debouncedSearchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
// Filter by selected tags (OR logic: show projects with ANY selected tag)
|
||||
if (selectedTagIds.length > 0) {
|
||||
filtered = filtered.filter((project) =>
|
||||
project.projectTags?.some((pt) =>
|
||||
selectedTagIds.includes(pt.tag.tagId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Then sort the filtered results
|
||||
const [field, direction] = sortBy.split("-");
|
||||
return [...filtered].sort((a, b) => {
|
||||
@@ -165,18 +194,13 @@ export const ShowProjects = () => {
|
||||
}
|
||||
return direction === "asc" ? comparison : -comparison;
|
||||
});
|
||||
}, [data, debouncedSearchQuery, sortBy]);
|
||||
}, [data, debouncedSearchQuery, sortBy, selectedTagIds]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BreadcrumbSidebar
|
||||
list={[{ name: "Projects", href: "/dashboard/projects" }]}
|
||||
/>
|
||||
{!isCloud && (
|
||||
<div className="absolute top-4 right-4">
|
||||
<TimeBadge />
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl ">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
@@ -190,9 +214,7 @@ export const ShowProjects = () => {
|
||||
Create and manage your projects
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{(auth?.role === "owner" ||
|
||||
auth?.role === "admin" ||
|
||||
auth?.canCreateProjects) && (
|
||||
{permissions?.project.create && (
|
||||
<div className="">
|
||||
<HandleProject />
|
||||
</div>
|
||||
@@ -218,29 +240,44 @@ export const ShowProjects = () => {
|
||||
|
||||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-48 max-sm:w-full">
|
||||
<ArrowUpDown className="size-4 text-muted-foreground" />
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Sort by..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
|
||||
<SelectItem value="name-desc">Name (Z-A)</SelectItem>
|
||||
<SelectItem value="createdAt-desc">
|
||||
Newest first
|
||||
</SelectItem>
|
||||
<SelectItem value="createdAt-asc">
|
||||
Oldest first
|
||||
</SelectItem>
|
||||
<SelectItem value="services-desc">
|
||||
Most services
|
||||
</SelectItem>
|
||||
<SelectItem value="services-asc">
|
||||
Least services
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-2">
|
||||
<TagFilter
|
||||
tags={
|
||||
availableTags?.map((tag) => ({
|
||||
id: tag.tagId,
|
||||
name: tag.name,
|
||||
color: tag.color || undefined,
|
||||
})) || []
|
||||
}
|
||||
selectedTags={selectedTagIds}
|
||||
onTagsChange={setSelectedTagIds}
|
||||
/>
|
||||
<div className="flex items-center gap-2 min-w-48 max-sm:w-full">
|
||||
<ArrowUpDown className="size-4 text-muted-foreground" />
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Sort by..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
|
||||
<SelectItem value="name-desc">
|
||||
Name (Z-A)
|
||||
</SelectItem>
|
||||
<SelectItem value="createdAt-desc">
|
||||
Newest first
|
||||
</SelectItem>
|
||||
<SelectItem value="createdAt-asc">
|
||||
Oldest first
|
||||
</SelectItem>
|
||||
<SelectItem value="services-desc">
|
||||
Most services
|
||||
</SelectItem>
|
||||
<SelectItem value="services-asc">
|
||||
Least services
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{filteredProjects?.length === 0 && (
|
||||
@@ -257,37 +294,30 @@ export const ShowProjects = () => {
|
||||
.map(
|
||||
(env) =>
|
||||
env.applications.length === 0 &&
|
||||
env.compose.length === 0 &&
|
||||
env.libsql.length === 0 &&
|
||||
env.mariadb.length === 0 &&
|
||||
env.mongo.length === 0 &&
|
||||
env.mysql.length === 0 &&
|
||||
env.postgres.length === 0 &&
|
||||
env.redis.length === 0 &&
|
||||
env.applications.length === 0 &&
|
||||
env.compose.length === 0,
|
||||
env.redis.length === 0,
|
||||
)
|
||||
.every(Boolean);
|
||||
|
||||
const totalServices = project?.environments
|
||||
.map(
|
||||
(env) =>
|
||||
env.applications.length +
|
||||
env.compose.length +
|
||||
env.libsql.length +
|
||||
env.mariadb.length +
|
||||
env.mongo.length +
|
||||
env.mysql.length +
|
||||
env.postgres.length +
|
||||
env.redis.length +
|
||||
env.applications.length +
|
||||
env.compose.length,
|
||||
env.redis.length,
|
||||
)
|
||||
.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 +343,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 ">
|
||||
@@ -443,6 +357,19 @@ export const ShowProjects = () => {
|
||||
{project.description}
|
||||
</span>
|
||||
|
||||
{project.projectTags &&
|
||||
project.projectTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{project.projectTags.map((pt) => (
|
||||
<TagBadge
|
||||
key={pt.tag.tagId}
|
||||
name={pt.tag.name}
|
||||
color={pt.tag.color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasNoEnvironments && (
|
||||
<div className="flex flex-row gap-2 items-center rounded-lg bg-yellow-50 p-2 mt-2 dark:bg-yellow-950">
|
||||
<AlertTriangle className="size-4 text-yellow-600 dark:text-yellow-400 shrink-0" />
|
||||
@@ -489,8 +416,7 @@ export const ShowProjects = () => {
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{(auth?.role === "owner" ||
|
||||
auth?.canDeleteProjects) && (
|
||||
{permissions?.project.delete && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger className="w-full">
|
||||
<DropdownMenuItem
|
||||
@@ -564,7 +490,7 @@ export const ShowProjects = () => {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardFooter className="pt-4">
|
||||
<div className="space-y-1 text-sm flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4">
|
||||
<div className="space-y-1 text-xs flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4">
|
||||
<DateTooltip date={project.createdAt}>
|
||||
Created
|
||||
</DateTooltip>
|
||||
|
||||
@@ -21,6 +21,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowGeneralRedis = ({ redisId }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canDeploy = permissions?.deployment.create ?? false;
|
||||
const { data, refetch } = api.redis.one.useQuery(
|
||||
{
|
||||
redisId,
|
||||
@@ -72,153 +74,158 @@ export const ShowGeneralRedis = ({ redisId }: Props) => {
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Deploy Redis"
|
||||
description="Are you sure you want to deploy this redis?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Downloads and sets up the Redis database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<DialogAction
|
||||
title="Reload Redis"
|
||||
description="Are you sure you want to reload this redis?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
redisId: redisId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Redis reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading Redis");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Restart the Redis service without rebuilding</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Start Redis"
|
||||
description="Are you sure you want to start this redis?"
|
||||
title="Deploy Redis"
|
||||
description="Are you sure you want to deploy this redis?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
redisId: redisId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Redis started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting Redis");
|
||||
});
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the Redis database (requires a previous
|
||||
successful setup)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop Redis"
|
||||
description="Are you sure you want to stop this redis?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
redisId: redisId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Redis stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping Redis");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running Redis database</p>
|
||||
<p>Downloads and sets up the Redis database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Reload Redis"
|
||||
description="Are you sure you want to reload this redis?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
redisId: redisId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Redis reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading Redis");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Restart the Redis service without rebuilding</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy &&
|
||||
(data?.applicationStatus === "idle" ? (
|
||||
<DialogAction
|
||||
title="Start Redis"
|
||||
description="Are you sure you want to start this redis?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
redisId: redisId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Redis started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting Redis");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the Redis database (requires a previous
|
||||
successful setup)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop Redis"
|
||||
description="Are you sure you want to stop this redis?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
redisId: redisId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Redis stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping Redis");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running Redis database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
import { StatusTooltip } from "../shared/status-tooltip";
|
||||
|
||||
@@ -56,7 +55,7 @@ export const SearchCommand = () => {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: session } = api.user.session.useQuery();
|
||||
const { data } = api.project.all.useQuery(undefined, {
|
||||
enabled: !!session,
|
||||
});
|
||||
@@ -174,6 +173,14 @@ export const SearchCommand = () => {
|
||||
>
|
||||
Projects
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
router.push("/dashboard/deployments");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Deployments
|
||||
</CommandItem>
|
||||
{!isCloud && (
|
||||
<>
|
||||
<CommandItem
|
||||
|
||||
@@ -91,7 +91,10 @@ export const ShowBilling = () => {
|
||||
api.stripe.upgradeSubscription.useMutation();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const [serverQuantity, setServerQuantity] = useState(3);
|
||||
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
|
||||
const [startupServerQuantity, setStartupServerQuantity] = useState(
|
||||
STARTUP_SERVERS_INCLUDED,
|
||||
);
|
||||
const [isAnnual, setIsAnnual] = useState(false);
|
||||
const [upgradeTier, setUpgradeTier] = useState<"hobby" | "startup" | null>(
|
||||
null,
|
||||
@@ -111,6 +114,12 @@ export const ShowBilling = () => {
|
||||
productId: string,
|
||||
) => {
|
||||
const stripe = await stripePromise;
|
||||
const serverQuantity =
|
||||
tier === "startup"
|
||||
? startupServerQuantity
|
||||
: tier === "hobby"
|
||||
? hobbyServerQuantity
|
||||
: hobbyServerQuantity;
|
||||
if (data && data.subscriptions.length === 0) {
|
||||
createCheckoutSession({
|
||||
tier,
|
||||
@@ -679,7 +688,7 @@ export const ShowBilling = () => {
|
||||
<p className="text-2xl font-semibold text-foreground">
|
||||
$
|
||||
{calculatePriceHobby(
|
||||
serverQuantity,
|
||||
hobbyServerQuantity,
|
||||
isAnnual,
|
||||
).toFixed(2)}
|
||||
/{isAnnual ? "yr" : "mo"}
|
||||
@@ -692,7 +701,8 @@ export const ShowBilling = () => {
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
$
|
||||
{(
|
||||
calculatePriceHobby(serverQuantity, true) / 12
|
||||
calculatePriceHobby(hobbyServerQuantity, true) /
|
||||
12
|
||||
).toFixed(2)}
|
||||
/mo
|
||||
</p>
|
||||
@@ -724,19 +734,19 @@ export const ShowBilling = () => {
|
||||
Servers:
|
||||
</span>
|
||||
<Button
|
||||
disabled={serverQuantity <= 1}
|
||||
disabled={hobbyServerQuantity <= 1}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setServerQuantity((q) => Math.max(1, q - 1))
|
||||
setHobbyServerQuantity((q) => Math.max(1, q - 1))
|
||||
}
|
||||
>
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<NumberInput
|
||||
value={serverQuantity}
|
||||
value={hobbyServerQuantity}
|
||||
onChange={(e) =>
|
||||
setServerQuantity(
|
||||
setHobbyServerQuantity(
|
||||
Math.max(
|
||||
1,
|
||||
Number(
|
||||
@@ -750,7 +760,7 @@ export const ShowBilling = () => {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setServerQuantity((q) => q + 1)}
|
||||
onClick={() => setHobbyServerQuantity((q) => q + 1)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -775,7 +785,7 @@ export const ShowBilling = () => {
|
||||
onClick={() =>
|
||||
handleCheckout("hobby", data!.hobbyProductId!)
|
||||
}
|
||||
disabled={serverQuantity < 1}
|
||||
disabled={hobbyServerQuantity < 1}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
@@ -806,7 +816,7 @@ export const ShowBilling = () => {
|
||||
<p className="text-2xl font-semibold text-foreground">
|
||||
$
|
||||
{calculatePriceStartup(
|
||||
serverQuantity,
|
||||
startupServerQuantity,
|
||||
isAnnual,
|
||||
).toFixed(2)}
|
||||
/{isAnnual ? "yr" : "mo"}
|
||||
@@ -819,7 +829,10 @@ export const ShowBilling = () => {
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
$
|
||||
{(
|
||||
calculatePriceStartup(serverQuantity, true) / 12
|
||||
calculatePriceStartup(
|
||||
startupServerQuantity,
|
||||
true,
|
||||
) / 12
|
||||
).toFixed(2)}
|
||||
/mo
|
||||
</p>
|
||||
@@ -856,13 +869,14 @@ export const ShowBilling = () => {
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
disabled={
|
||||
serverQuantity <= STARTUP_SERVERS_INCLUDED
|
||||
startupServerQuantity <=
|
||||
STARTUP_SERVERS_INCLUDED
|
||||
}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() =>
|
||||
setServerQuantity((q) =>
|
||||
setStartupServerQuantity((q) =>
|
||||
Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
|
||||
)
|
||||
}
|
||||
@@ -870,9 +884,9 @@ export const ShowBilling = () => {
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<NumberInput
|
||||
value={serverQuantity}
|
||||
value={startupServerQuantity}
|
||||
onChange={(e) =>
|
||||
setServerQuantity(
|
||||
setStartupServerQuantity(
|
||||
Math.max(
|
||||
STARTUP_SERVERS_INCLUDED,
|
||||
Number(
|
||||
@@ -887,7 +901,9 @@ export const ShowBilling = () => {
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setServerQuantity((q) => q + 1)}
|
||||
onClick={() =>
|
||||
setStartupServerQuantity((q) => q + 1)
|
||||
}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -917,7 +933,7 @@ export const ShowBilling = () => {
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
serverQuantity < STARTUP_SERVERS_INCLUDED
|
||||
startupServerQuantity < STARTUP_SERVERS_INCLUDED
|
||||
}
|
||||
>
|
||||
Get Started
|
||||
@@ -1009,7 +1025,7 @@ export const ShowBilling = () => {
|
||||
<p className="text-2xl font-semibold tracking-tight text-primary ">
|
||||
${" "}
|
||||
{calculatePrice(
|
||||
serverQuantity,
|
||||
hobbyServerQuantity,
|
||||
isAnnual,
|
||||
).toFixed(2)}{" "}
|
||||
USD
|
||||
@@ -1018,7 +1034,10 @@ export const ShowBilling = () => {
|
||||
<p className="text-base font-semibold tracking-tight text-muted-foreground">
|
||||
${" "}
|
||||
{(
|
||||
calculatePrice(serverQuantity, isAnnual) / 12
|
||||
calculatePrice(
|
||||
hobbyServerQuantity,
|
||||
isAnnual,
|
||||
) / 12
|
||||
).toFixed(2)}{" "}
|
||||
/ Month USD
|
||||
</p>
|
||||
@@ -1026,9 +1045,10 @@ export const ShowBilling = () => {
|
||||
) : (
|
||||
<p className="text-2xl font-semibold tracking-tight text-primary ">
|
||||
${" "}
|
||||
{calculatePrice(serverQuantity, isAnnual).toFixed(
|
||||
2,
|
||||
)}{" "}
|
||||
{calculatePrice(
|
||||
hobbyServerQuantity,
|
||||
isAnnual,
|
||||
).toFixed(2)}{" "}
|
||||
USD
|
||||
</p>
|
||||
)}
|
||||
@@ -1071,26 +1091,28 @@ export const ShowBilling = () => {
|
||||
<div className="flex flex-col gap-2 mt-4">
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{serverQuantity} Servers
|
||||
{hobbyServerQuantity} Servers
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
disabled={serverQuantity <= 1}
|
||||
disabled={hobbyServerQuantity <= 1}
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (serverQuantity <= 1) return;
|
||||
if (hobbyServerQuantity <= 1) return;
|
||||
|
||||
setServerQuantity(serverQuantity - 1);
|
||||
setHobbyServerQuantity(
|
||||
hobbyServerQuantity - 1,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<NumberInput
|
||||
value={serverQuantity}
|
||||
value={hobbyServerQuantity}
|
||||
onChange={(e) => {
|
||||
setServerQuantity(
|
||||
setHobbyServerQuantity(
|
||||
e.target.value as unknown as number,
|
||||
);
|
||||
}}
|
||||
@@ -1099,7 +1121,9 @@ export const ShowBilling = () => {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setServerQuantity(serverQuantity + 1);
|
||||
setHobbyServerQuantity(
|
||||
hobbyServerQuantity + 1,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
@@ -1125,7 +1149,7 @@ export const ShowBilling = () => {
|
||||
onClick={async () => {
|
||||
handleCheckout("legacy", product.id);
|
||||
}}
|
||||
disabled={serverQuantity < 1}
|
||||
disabled={hobbyServerQuantity < 1}
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
|
||||
@@ -18,6 +18,7 @@ export const ShowCertificates = () => {
|
||||
const { mutateAsync, isPending: isRemoving } =
|
||||
api.certificates.remove.useMutation();
|
||||
const { data, isPending, refetch } = api.certificates.all.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -53,7 +54,7 @@ export const ShowCertificates = () => {
|
||||
<span className="text-base text-muted-foreground text-center">
|
||||
You don't have any certificates created
|
||||
</span>
|
||||
<AddCertificate />
|
||||
{permissions?.certificate.create && <AddCertificate />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
@@ -101,47 +102,52 @@ export const ShowCertificates = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-1">
|
||||
<DialogAction
|
||||
title="Delete Certificate"
|
||||
description="Are you sure you want to delete this certificate?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
certificateId: certificate.certificateId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"Certificate deleted successfully",
|
||||
);
|
||||
refetch();
|
||||
{permissions?.certificate.delete && (
|
||||
<div className="flex flex-row gap-1">
|
||||
<DialogAction
|
||||
title="Delete Certificate"
|
||||
description="Are you sure you want to delete this certificate?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
certificateId:
|
||||
certificate.certificateId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error deleting certificate",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isRemoving}
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"Certificate deleted successfully",
|
||||
);
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error deleting certificate",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||
<AddCertificate />
|
||||
</div>
|
||||
{permissions?.certificate.create && (
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||
<AddCertificate />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -14,13 +14,13 @@ export const extractExpirationDate = (certData: string): Date | null => {
|
||||
|
||||
// Helper: read ASN.1 length field
|
||||
function readLength(pos: number): { length: number; offset: number } {
|
||||
// biome-ignore lint/style/noParameterAssign: <explanation>
|
||||
// biome-ignore lint/style/noParameterAssign: this is for dynamic length calculation
|
||||
let len = der[pos++];
|
||||
if (len & 0x80) {
|
||||
const bytes = len & 0x7f;
|
||||
len = 0;
|
||||
for (let i = 0; i < bytes; i++) {
|
||||
// biome-ignore lint/style/noParameterAssign: <explanation>
|
||||
// biome-ignore lint/style/noParameterAssign: this is for dynamic length calculation
|
||||
len = (len << 8) + der[pos++];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export const ShowRegistry = () => {
|
||||
const { mutateAsync, isPending: isRemoving } =
|
||||
api.registry.remove.useMutation();
|
||||
const { data, isPending, refetch } = api.registry.all.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -44,7 +45,7 @@ export const ShowRegistry = () => {
|
||||
<span className="text-base text-muted-foreground text-center">
|
||||
You don't have any registry configurations
|
||||
</span>
|
||||
<HandleRegistry />
|
||||
{permissions?.registry.create && <HandleRegistry />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
@@ -73,45 +74,49 @@ export const ShowRegistry = () => {
|
||||
registryId={registry.registryId}
|
||||
/>
|
||||
|
||||
<DialogAction
|
||||
title="Delete Registry"
|
||||
description="Are you sure you want to delete this registry configuration?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
registryId: registry.registryId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"Registry configuration deleted successfully",
|
||||
);
|
||||
refetch();
|
||||
{permissions?.registry.delete && (
|
||||
<DialogAction
|
||||
title="Delete Registry"
|
||||
description="Are you sure you want to delete this registry configuration?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
registryId: registry.registryId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error deleting registry configuration",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isRemoving}
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"Registry configuration deleted successfully",
|
||||
);
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error deleting registry configuration",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||
<HandleRegistry />
|
||||
</div>
|
||||
{permissions?.registry.create && (
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||
<HandleRegistry />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -16,6 +16,7 @@ export const ShowDestinations = () => {
|
||||
const { data, isPending, refetch } = api.destination.all.useQuery();
|
||||
const { mutateAsync, isPending: isRemoving } =
|
||||
api.destination.remove.useMutation();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
@@ -45,7 +46,7 @@ export const ShowDestinations = () => {
|
||||
To create a backup it is required to set at least 1
|
||||
provider.
|
||||
</span>
|
||||
<HandleDestinations />
|
||||
{permissions?.destination.create && <HandleDestinations />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
@@ -71,43 +72,49 @@ export const ShowDestinations = () => {
|
||||
<HandleDestinations
|
||||
destinationId={destination.destinationId}
|
||||
/>
|
||||
<DialogAction
|
||||
title="Delete Destination"
|
||||
description="Are you sure you want to delete this destination?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
destinationId: destination.destinationId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"Destination deleted successfully",
|
||||
);
|
||||
refetch();
|
||||
{permissions?.destination.delete && (
|
||||
<DialogAction
|
||||
title="Delete Destination"
|
||||
description="Are you sure you want to delete this destination?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
destinationId: destination.destinationId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting destination");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isRemoving}
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"Destination deleted successfully",
|
||||
);
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error deleting destination",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||
<HandleDestinations />
|
||||
</div>
|
||||
{permissions?.destination.create && (
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||
<HandleDestinations />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -12,13 +12,13 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const AddGithubProvider = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: activeOrganization } = api.organization.active.useQuery();
|
||||
|
||||
const { data: session } = api.user.session.useQuery();
|
||||
const { data } = api.user.get.useQuery();
|
||||
const [manifest, setManifest] = useState("");
|
||||
const [isOrganization, setIsOrganization] = useState(false);
|
||||
@@ -30,7 +30,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 +52,7 @@ export const AddGithubProvider = () => {
|
||||
);
|
||||
|
||||
setManifest(manifest);
|
||||
}, [data?.id]);
|
||||
}, [activeOrganization?.id, session?.user?.id]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
@@ -98,8 +98,8 @@ export const AddGithubProvider = () => {
|
||||
<form
|
||||
action={
|
||||
isOrganization
|
||||
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}`
|
||||
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}`
|
||||
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
|
||||
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
|
||||
}
|
||||
method="post"
|
||||
>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
DiscordIcon,
|
||||
MattermostIcon,
|
||||
GotifyIcon,
|
||||
LarkIcon,
|
||||
NtfyIcon,
|
||||
@@ -134,6 +135,14 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
||||
priority: z.number().min(1).max(5).default(3),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("mattermost"),
|
||||
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
|
||||
channel: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("pushover"),
|
||||
@@ -210,6 +219,10 @@ export const notificationsMap = {
|
||||
icon: <NtfyIcon />,
|
||||
label: "ntfy",
|
||||
},
|
||||
mattermost: {
|
||||
icon: <MattermostIcon />,
|
||||
label: "Mattermost",
|
||||
},
|
||||
pushover: {
|
||||
icon: <PushoverIcon />,
|
||||
label: "Pushover",
|
||||
@@ -253,14 +266,16 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
api.notification.testGotifyConnection.useMutation();
|
||||
const { mutateAsync: testNtfyConnection, isPending: isLoadingNtfy } =
|
||||
api.notification.testNtfyConnection.useMutation();
|
||||
const {
|
||||
mutateAsync: testMattermostConnection,
|
||||
isPending: isLoadingMattermost,
|
||||
} = api.notification.testMattermostConnection.useMutation();
|
||||
const { mutateAsync: testLarkConnection, isPending: isLoadingLark } =
|
||||
api.notification.testLarkConnection.useMutation();
|
||||
const { mutateAsync: testTeamsConnection, isPending: isLoadingTeams } =
|
||||
api.notification.testTeamsConnection.useMutation();
|
||||
|
||||
const { mutateAsync: testCustomConnection, isPending: isLoadingCustom } =
|
||||
api.notification.testCustomConnection.useMutation();
|
||||
|
||||
const { mutateAsync: testPushoverConnection, isPending: isLoadingPushover } =
|
||||
api.notification.testPushoverConnection.useMutation();
|
||||
|
||||
@@ -288,6 +303,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
const ntfyMutation = notificationId
|
||||
? api.notification.updateNtfy.useMutation()
|
||||
: api.notification.createNtfy.useMutation();
|
||||
const mattermostMutation = notificationId
|
||||
? api.notification.updateMattermost.useMutation()
|
||||
: api.notification.createMattermost.useMutation();
|
||||
const larkMutation = notificationId
|
||||
? api.notification.updateLark.useMutation()
|
||||
: api.notification.createLark.useMutation();
|
||||
@@ -438,6 +456,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
} else if (notification.notificationType === "mattermost") {
|
||||
form.reset({
|
||||
appBuildError: notification.appBuildError,
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
type: notification.notificationType,
|
||||
webhookUrl: notification.mattermost?.webhookUrl,
|
||||
channel: notification.mattermost?.channel || "",
|
||||
username: notification.mattermost?.username || "",
|
||||
name: notification.name,
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
} else if (notification.notificationType === "lark") {
|
||||
form.reset({
|
||||
appBuildError: notification.appBuildError,
|
||||
@@ -516,6 +549,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
resend: resendMutation,
|
||||
gotify: gotifyMutation,
|
||||
ntfy: ntfyMutation,
|
||||
mattermost: mattermostMutation,
|
||||
lark: larkMutation,
|
||||
teams: teamsMutation,
|
||||
custom: customMutation,
|
||||
@@ -646,6 +680,22 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
notificationId: notificationId || "",
|
||||
ntfyId: notification?.ntfyId || "",
|
||||
});
|
||||
} else if (data.type === "mattermost") {
|
||||
promise = mattermostMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
webhookUrl: data.webhookUrl,
|
||||
channel: data.channel || undefined,
|
||||
username: data.username || undefined,
|
||||
name: data.name,
|
||||
dockerCleanup: dockerCleanup,
|
||||
notificationId: notificationId || "",
|
||||
mattermostId: notification?.mattermostId || "",
|
||||
serverThreshold: serverThreshold,
|
||||
});
|
||||
} else if (data.type === "lark") {
|
||||
promise = larkMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
@@ -737,6 +787,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
});
|
||||
setVisible(false);
|
||||
await utils.notification.all.invalidate();
|
||||
if (notificationId) {
|
||||
await utils.notification.one.invalidate({ notificationId });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
@@ -1403,6 +1456,62 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "mattermost" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://your-mattermost.com/hooks/xxx-generatedkey-xxx"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="channel"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Channel</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="deployments" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional. Channel to post to (without #).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Dokploy" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional. Display name for the webhook.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "custom" && (
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
@@ -1489,6 +1598,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === "lark" && (
|
||||
<>
|
||||
<FormField
|
||||
@@ -1849,6 +1959,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
isLoadingResend ||
|
||||
isLoadingGotify ||
|
||||
isLoadingNtfy ||
|
||||
isLoadingMattermost ||
|
||||
isLoadingLark ||
|
||||
isLoadingTeams ||
|
||||
isLoadingCustom ||
|
||||
@@ -1908,6 +2019,12 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
accessToken: data.accessToken || "",
|
||||
priority: data.priority ?? 0,
|
||||
});
|
||||
} else if (data.type === "mattermost") {
|
||||
await testMattermostConnection({
|
||||
webhookUrl: data.webhookUrl,
|
||||
channel: data.channel || undefined,
|
||||
username: data.username || undefined,
|
||||
});
|
||||
} else if (data.type === "lark") {
|
||||
await testLarkConnection({
|
||||
webhookUrl: data.webhookUrl,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DiscordIcon,
|
||||
GotifyIcon,
|
||||
LarkIcon,
|
||||
MattermostIcon,
|
||||
NtfyIcon,
|
||||
ResendIcon,
|
||||
SlackIcon,
|
||||
@@ -26,6 +27,7 @@ export const ShowNotifications = () => {
|
||||
const { data, isPending, refetch } = api.notification.all.useQuery();
|
||||
const { mutateAsync, isPending: isRemoving } =
|
||||
api.notification.remove.useMutation();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -56,7 +58,9 @@ export const ShowNotifications = () => {
|
||||
To send notifications it is required to set at least 1
|
||||
provider.
|
||||
</span>
|
||||
<HandleNotifications />
|
||||
{permissions?.notification.create && (
|
||||
<HandleNotifications />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
@@ -118,6 +122,12 @@ export const ShowNotifications = () => {
|
||||
<TeamsIcon className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{notification.notificationType ===
|
||||
"mattermost" && (
|
||||
<div className="flex items-center justify-center rounded-lg">
|
||||
<MattermostIcon className="size-7" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notification.name}
|
||||
</span>
|
||||
@@ -126,45 +136,50 @@ export const ShowNotifications = () => {
|
||||
notificationId={notification.notificationId}
|
||||
/>
|
||||
|
||||
<DialogAction
|
||||
title="Delete Notification"
|
||||
description="Are you sure you want to delete this notification?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
notificationId: notification.notificationId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"Notification deleted successfully",
|
||||
);
|
||||
refetch();
|
||||
{permissions?.notification.delete && (
|
||||
<DialogAction
|
||||
title="Delete Notification"
|
||||
description="Are you sure you want to delete this notification?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
notificationId:
|
||||
notification.notificationId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error deleting notification",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isRemoving}
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"Notification deleted successfully",
|
||||
);
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error deleting notification",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||
<HandleNotifications />
|
||||
</div>
|
||||
{permissions?.notification.create && (
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||
<HandleNotifications />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
@@ -409,7 +410,10 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
||||
<FormControl>
|
||||
<Input placeholder="root" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
Use "root" or a non-root user with passwordless
|
||||
sudo access.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -118,9 +118,10 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => {
|
||||
</div>
|
||||
) : (
|
||||
<div id="hook-form-add-gitlab" className="grid w-full gap-4">
|
||||
<AlertBlock type="warning">
|
||||
Using a root user is required to ensure everything works as
|
||||
expected.
|
||||
<AlertBlock type="info">
|
||||
You can connect as root or as a non-root user with passwordless
|
||||
sudo access. If using a non-root user, ensure passwordless sudo is
|
||||
configured.
|
||||
</AlertBlock>
|
||||
|
||||
<Tabs defaultValue="ssh-keys">
|
||||
|
||||
@@ -59,6 +59,7 @@ export const ShowServers = () => {
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: canCreateMoreServers } =
|
||||
api.stripe.canCreateMoreServers.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -115,7 +116,7 @@ export const ShowServers = () => {
|
||||
Start adding servers to deploy your applications
|
||||
remotely.
|
||||
</span>
|
||||
<HandleServers />
|
||||
{permissions?.server.create && <HandleServers />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
@@ -362,66 +363,71 @@ export const ShowServers = () => {
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<DialogAction
|
||||
disabled={!canDelete}
|
||||
title={
|
||||
canDelete
|
||||
? "Delete Server"
|
||||
: "Server has active services"
|
||||
}
|
||||
description={
|
||||
canDelete ? (
|
||||
"This will delete the server and all associated data"
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
You can not delete this
|
||||
server because it has
|
||||
active services.
|
||||
<AlertBlock type="warning">
|
||||
You have active services
|
||||
associated with this
|
||||
server, please delete
|
||||
them first.
|
||||
</AlertBlock>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
serverId: server.serverId,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success(
|
||||
`Server ${server.name} deleted successfully`,
|
||||
);
|
||||
{permissions?.server.delete && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<DialogAction
|
||||
disabled={!canDelete}
|
||||
title={
|
||||
canDelete
|
||||
? "Delete Server"
|
||||
: "Server has active services"
|
||||
}
|
||||
description={
|
||||
canDelete ? (
|
||||
"This will delete the server and all associated data"
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
You can not delete this
|
||||
server because it has
|
||||
active services.
|
||||
<AlertBlock type="warning">
|
||||
You have active
|
||||
services associated
|
||||
with this server,
|
||||
please delete them
|
||||
first.
|
||||
</AlertBlock>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
serverId: server.serverId,
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-9 w-9 ${canDelete ? "text-destructive hover:text-destructive hover:bg-destructive/10" : "text-muted-foreground hover:bg-muted"}`}
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success(
|
||||
`Server ${server.name} deleted successfully`,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
err.message,
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{canDelete
|
||||
? "Delete Server"
|
||||
: "Cannot delete - has active services"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-9 w-9 ${canDelete ? "text-destructive hover:text-destructive hover:bg-destructive/10" : "text-muted-foreground hover:bg-muted"}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{canDelete
|
||||
? "Delete Server"
|
||||
: "Cannot delete - has active services"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
@@ -431,13 +437,15 @@ export const ShowServers = () => {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mt-4">
|
||||
{data && data?.length > 0 && (
|
||||
<div>
|
||||
<HandleServers />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{permissions?.server.create && (
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mt-4">
|
||||
{data && data?.length > 0 && (
|
||||
<div>
|
||||
<HandleServers />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -163,6 +163,29 @@ export const ValidateServer = ({ serverId }: Props) => {
|
||||
: "Not Created"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Privilege Mode"
|
||||
isEnabled={
|
||||
data?.privilegeMode === "root" ||
|
||||
data?.privilegeMode === "sudo"
|
||||
}
|
||||
description={
|
||||
data?.privilegeMode === "root"
|
||||
? "Running as root"
|
||||
: data?.privilegeMode === "sudo"
|
||||
? "Running with sudo"
|
||||
: "No sudo access (required for non-root)"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Docker Group"
|
||||
isEnabled={data?.dockerGroupMember}
|
||||
description={
|
||||
data?.dockerGroupMember
|
||||
? "User is in docker group"
|
||||
: "User is not in docker group"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ export const ShowDestinations = () => {
|
||||
const { data, isPending, refetch } = api.sshKey.all.useQuery();
|
||||
const { mutateAsync, isPending: isRemoving } =
|
||||
api.sshKey.remove.useMutation();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -46,7 +47,7 @@ export const ShowDestinations = () => {
|
||||
<span className="text-base text-muted-foreground text-center">
|
||||
You don't have any SSH keys
|
||||
</span>
|
||||
<HandleSSHKeys />
|
||||
{permissions?.sshKeys.create && <HandleSSHKeys />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
@@ -84,43 +85,47 @@ export const ShowDestinations = () => {
|
||||
<div className="flex flex-row gap-1">
|
||||
<HandleSSHKeys sshKeyId={sshKey.sshKeyId} />
|
||||
|
||||
<DialogAction
|
||||
title="Delete SSH Key"
|
||||
description="Are you sure you want to delete this SSH Key?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
sshKeyId: sshKey.sshKeyId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"SSH Key deleted successfully",
|
||||
);
|
||||
refetch();
|
||||
{permissions?.sshKeys.delete && (
|
||||
<DialogAction
|
||||
title="Delete SSH Key"
|
||||
description="Are you sure you want to delete this SSH Key?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
sshKeyId: sshKey.sshKeyId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting SSH Key");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isRemoving}
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"SSH Key deleted successfully",
|
||||
);
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting SSH Key");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||
<HandleSSHKeys />
|
||||
</div>
|
||||
{permissions?.sshKeys.create && (
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||
<HandleSSHKeys />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
239
apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx
Normal file
239
apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Palette, PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { TagBadge } from "@/components/shared/tag-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const TagSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, "Tag name is required")
|
||||
.max(50, "Tag name must be less than 50 characters")
|
||||
.refine(
|
||||
(name) => {
|
||||
const trimmedName = name.trim();
|
||||
const validNameRegex =
|
||||
/^[\p{L}\p{N}_-][\p{L}\p{N}\s_.-]*[\p{L}\p{N}_-]$/u;
|
||||
return validNameRegex.test(trimmedName);
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Tag name must start and end with a letter, number, hyphen or underscore. Spaces are allowed in between.",
|
||||
},
|
||||
)
|
||||
.transform((name) => name.trim()),
|
||||
color: z.string().optional(),
|
||||
});
|
||||
|
||||
type Tag = z.infer<typeof TagSchema>;
|
||||
|
||||
interface HandleTagProps {
|
||||
tagId?: string;
|
||||
}
|
||||
|
||||
export const HandleTag = ({ tagId }: HandleTagProps) => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { mutateAsync, error, isError } = tagId
|
||||
? api.tag.update.useMutation()
|
||||
: api.tag.create.useMutation();
|
||||
|
||||
const { data: tag } = api.tag.one.useQuery(
|
||||
{
|
||||
tagId: tagId || "",
|
||||
},
|
||||
{
|
||||
enabled: !!tagId,
|
||||
},
|
||||
);
|
||||
|
||||
const form = useForm<Tag>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
color: "#3b82f6",
|
||||
},
|
||||
resolver: zodResolver(TagSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (tag) {
|
||||
form.reset({
|
||||
name: tag.name ?? "",
|
||||
color: tag.color ?? "#3b82f6",
|
||||
});
|
||||
} else {
|
||||
form.reset({
|
||||
name: "",
|
||||
color: "#3b82f6",
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, tag]);
|
||||
|
||||
const onSubmit = async (data: Tag) => {
|
||||
await mutateAsync({
|
||||
name: data.name,
|
||||
color: data.color,
|
||||
tagId: tagId || "",
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.tag.all.invalidate();
|
||||
toast.success(tagId ? "Tag Updated" : "Tag Created");
|
||||
setIsOpen(false);
|
||||
form.reset();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(tagId ? "Error updating tag" : "Error creating tag");
|
||||
});
|
||||
};
|
||||
|
||||
const colorValue = form.watch("color");
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{tagId ? (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<PenBoxIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Create Tag
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{tagId ? "Update" : "Create"} Tag</DialogTitle>
|
||||
<DialogDescription>
|
||||
{tagId
|
||||
? "Update the tag name and color"
|
||||
: "Create a new tag to organize your projects"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-tag"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., Production, Client, Internal"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="color"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Color (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-3">
|
||||
<FormLabel
|
||||
className="relative flex items-center justify-center w-12 h-12 rounded-md border-2 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
style={{
|
||||
backgroundColor: field.value || "#3b82f6",
|
||||
}}
|
||||
onClick={() => colorInputRef.current?.click()}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
{!field.value && (
|
||||
<Palette className="h-5 w-5 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={colorInputRef}
|
||||
type="color"
|
||||
className="absolute opacity-0 pointer-events-none w-12 h-12 top-0 left-0"
|
||||
value={field.value || "#3b82f6"}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormLabel>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="#3b82f6"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value.startsWith("#") || value === "") {
|
||||
field.onChange(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FormDescription className="mt-1">
|
||||
Choose a color to easily identify this tag
|
||||
</FormDescription>
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{colorValue && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Preview:</span>
|
||||
<TagBadge
|
||||
name={form.watch("name") || "Tag Name"}
|
||||
color={colorValue}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={form.formState.isSubmitting}
|
||||
form="hook-form-tag"
|
||||
type="submit"
|
||||
>
|
||||
{tagId ? "Update" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
124
apps/dokploy/components/dashboard/settings/tags/tag-manager.tsx
Normal file
124
apps/dokploy/components/dashboard/settings/tags/tag-manager.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Loader2, TagIcon, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { TagBadge } from "@/components/shared/tag-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { HandleTag } from "./handle-tag";
|
||||
|
||||
export const TagManager = () => {
|
||||
const utils = api.useUtils();
|
||||
const { data: tags, isPending } = api.tag.all.useQuery();
|
||||
const { mutateAsync: deleteTag, isPending: isRemoving } =
|
||||
api.tag.remove.useMutation();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<TagIcon className="size-6 text-muted-foreground self-center" />
|
||||
Tags
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Create and manage tags to organize your projects
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
{isPending ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!tags || tags.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<TagIcon className="size-6 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground text-center">
|
||||
No tags yet. Create your first tag to start organizing
|
||||
projects.
|
||||
</span>
|
||||
{permissions?.tag.create && <HandleTag />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
<div className="flex flex-col gap-4 rounded-lg">
|
||||
{tags.map((tag) => (
|
||||
<div
|
||||
key={tag.tagId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center gap-3">
|
||||
<TagBadge name={tag.name} color={tag.color} />
|
||||
{tag.color && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{tag.color}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row gap-1 items-center">
|
||||
{permissions?.tag.update && (
|
||||
<HandleTag tagId={tag.tagId} />
|
||||
)}
|
||||
{permissions?.tag.delete && (
|
||||
<DialogAction
|
||||
title="Delete Tag"
|
||||
description={`Are you sure you want to delete the tag "${tag.name}"? This will remove the tag from all projects. This action cannot be undone.`}
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteTag({
|
||||
tagId: tag.tagId,
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.tag.all.invalidate();
|
||||
toast.success(
|
||||
"Tag deleted successfully",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting tag");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{permissions?.tag.create && (
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||
<HandleTag />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const addInvitation = z.object({
|
||||
@@ -40,7 +39,7 @@ const addInvitation = z.object({
|
||||
.string()
|
||||
.min(1, "Email is required")
|
||||
.email({ message: "Invalid email" }),
|
||||
role: z.enum(["member", "admin"]),
|
||||
role: z.string().min(1, "Role is required"),
|
||||
notificationId: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -49,13 +48,14 @@ type AddInvitation = z.infer<typeof addInvitation>;
|
||||
export const AddInvitation = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: emailProviders } =
|
||||
api.notification.getEmailProviders.useQuery();
|
||||
const { mutateAsync: inviteMember, isPending: isInviting } =
|
||||
api.organization.inviteMember.useMutation();
|
||||
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
|
||||
const { data: customRoles } = api.customRole.all.useQuery();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
||||
|
||||
const form = useForm<AddInvitation>({
|
||||
defaultValues: {
|
||||
@@ -70,19 +70,15 @@ export const AddInvitation = () => {
|
||||
}, [form, form.formState.isSubmitSuccessful, form.reset]);
|
||||
|
||||
const onSubmit = async (data: AddInvitation) => {
|
||||
setIsLoading(true);
|
||||
const result = await authClient.organization.inviteMember({
|
||||
email: data.email.toLowerCase(),
|
||||
role: data.role,
|
||||
organizationId: activeOrganization?.id,
|
||||
});
|
||||
try {
|
||||
const result = await inviteMember({
|
||||
email: data.email.toLowerCase(),
|
||||
role: data.role,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message || "");
|
||||
} else {
|
||||
if (!isCloud && data.notificationId) {
|
||||
await sendInvitation({
|
||||
invitationId: result.data.id,
|
||||
invitationId: result!.id,
|
||||
notificationId: data.notificationId || "",
|
||||
})
|
||||
.then(() => {
|
||||
@@ -96,10 +92,11 @@ export const AddInvitation = () => {
|
||||
}
|
||||
setError(null);
|
||||
setOpen(false);
|
||||
} catch (error: any) {
|
||||
setError(error.message || "Failed to create invitation");
|
||||
}
|
||||
|
||||
utils.organization.allInvitations.invalidate();
|
||||
setIsLoading(false);
|
||||
};
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -159,6 +156,11 @@ export const AddInvitation = () => {
|
||||
<SelectContent>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
{customRoles?.map((role) => (
|
||||
<SelectItem key={role.role} value={role.role}>
|
||||
{role.role}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
@@ -212,7 +214,7 @@ export const AddInvitation = () => {
|
||||
)}
|
||||
<DialogFooter className="flex w-full flex-row">
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
isLoading={isInviting}
|
||||
form="hook-form-add-invitation"
|
||||
type="submit"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
@@ -42,7 +46,8 @@ export type Services = {
|
||||
| "mysql"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "compose";
|
||||
| "compose"
|
||||
| "libsql";
|
||||
description?: string | null;
|
||||
id: string;
|
||||
createdAt: string;
|
||||
@@ -132,6 +137,18 @@ export const extractServices = (data: Environment | undefined) => {
|
||||
serverId: item.serverId,
|
||||
})) ?? []) as Services[];
|
||||
|
||||
const libsql: Services[] =
|
||||
data?.libsql?.map((item) => ({
|
||||
appName: item.appName,
|
||||
name: item.name,
|
||||
type: "libsql" as const,
|
||||
id: item.libsqlId,
|
||||
createdAt: item.createdAt,
|
||||
status: item.applicationStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
})) || [];
|
||||
|
||||
applications.push(
|
||||
...mysql,
|
||||
...redis,
|
||||
@@ -139,6 +156,7 @@ export const extractServices = (data: Environment | undefined) => {
|
||||
...postgres,
|
||||
...mariadb,
|
||||
...compose,
|
||||
...libsql,
|
||||
);
|
||||
|
||||
applications.sort((a, b) => {
|
||||
@@ -169,11 +187,15 @@ type AddPermissions = z.infer<typeof addPermissions>;
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export const AddUserPermissions = ({ userId }: Props) => {
|
||||
export const AddUserPermissions = ({ userId, role }: Props) => {
|
||||
const isCustomRole = !!role && !["owner", "admin", "member"].includes(role);
|
||||
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(
|
||||
{
|
||||
@@ -278,226 +300,237 @@ export const AddUserPermissions = ({ userId }: Props) => {
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="canCreateProjects"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Create Projects</FormLabel>
|
||||
<FormDescription>
|
||||
Allow the user to create projects
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="canDeleteProjects"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Delete Projects</FormLabel>
|
||||
<FormDescription>
|
||||
Allow the user to delete projects
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="canCreateServices"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Create Services</FormLabel>
|
||||
<FormDescription>
|
||||
Allow the user to create services
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="canDeleteServices"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Delete Services</FormLabel>
|
||||
<FormDescription>
|
||||
Allow the user to delete services
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="canCreateEnvironments"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Create Environments</FormLabel>
|
||||
<FormDescription>
|
||||
Allow the user to create environments
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="canDeleteEnvironments"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Delete Environments</FormLabel>
|
||||
<FormDescription>
|
||||
Allow the user to delete environments
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="canAccessToTraefikFiles"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Access to Traefik Files</FormLabel>
|
||||
<FormDescription>
|
||||
Allow the user to access to the Traefik Tab Files
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="canAccessToDocker"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Access to Docker</FormLabel>
|
||||
<FormDescription>
|
||||
Allow the user to access to the Docker Tab
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="canAccessToAPI"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Access to API/CLI</FormLabel>
|
||||
<FormDescription>
|
||||
Allow the user to access to the API/CLI
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="canAccessToSSHKeys"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Access to SSH Keys</FormLabel>
|
||||
<FormDescription>
|
||||
Allow to users to access to the SSH Keys section
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="canAccessToGitProviders"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Access to Git Providers</FormLabel>
|
||||
<FormDescription>
|
||||
Allow to users to access to the Git Providers section
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{isCustomRole && (
|
||||
<div className="md:col-span-2 rounded-lg border p-3 bg-muted/50 text-sm text-muted-foreground">
|
||||
This user has a custom role assigned. Capabilities are defined
|
||||
by the role. You can still manage which projects, environments,
|
||||
and services they can access below.
|
||||
</div>
|
||||
)}
|
||||
{!isCustomRole && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="canCreateProjects"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Create Projects</FormLabel>
|
||||
<FormDescription>
|
||||
Allow the user to create projects
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="canDeleteProjects"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Delete Projects</FormLabel>
|
||||
<FormDescription>
|
||||
Allow the user to delete projects
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="canCreateServices"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Create Services</FormLabel>
|
||||
<FormDescription>
|
||||
Allow the user to create services
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="canDeleteServices"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Delete Services</FormLabel>
|
||||
<FormDescription>
|
||||
Allow the user to delete services
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="canCreateEnvironments"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Create Environments</FormLabel>
|
||||
<FormDescription>
|
||||
Allow the user to create environments
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="canDeleteEnvironments"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Delete Environments</FormLabel>
|
||||
<FormDescription>
|
||||
Allow the user to delete environments
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="canAccessToTraefikFiles"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Access to Traefik Files</FormLabel>
|
||||
<FormDescription>
|
||||
Allow the user to access to the Traefik Tab Files
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="canAccessToDocker"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Access to Docker</FormLabel>
|
||||
<FormDescription>
|
||||
Allow the user to access to the Docker Tab
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="canAccessToAPI"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Access to API/CLI</FormLabel>
|
||||
<FormDescription>
|
||||
Allow the user to access to the API/CLI
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="canAccessToSSHKeys"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Access to SSH Keys</FormLabel>
|
||||
<FormDescription>
|
||||
Allow to users to access to the SSH Keys section
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="canAccessToGitProviders"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Access to Git Providers</FormLabel>
|
||||
<FormDescription>
|
||||
Allow to users to access to the Git Providers section
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="accessedProjects"
|
||||
|
||||
@@ -34,14 +34,14 @@ import {
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const changeRoleSchema = z.object({
|
||||
role: z.enum(["admin", "member"]),
|
||||
role: z.string().min(1),
|
||||
});
|
||||
|
||||
type ChangeRoleSchema = z.infer<typeof changeRoleSchema>;
|
||||
|
||||
interface Props {
|
||||
memberId: string;
|
||||
currentRole: "admin" | "member";
|
||||
currentRole: string;
|
||||
userEmail: string;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,10 @@ export const ChangeRole = ({ memberId, currentRole, userEmail }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data: customRoles } = api.customRole.all.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
const { mutateAsync, isError, error, isPending } =
|
||||
api.organization.updateMemberRole.useMutation();
|
||||
|
||||
@@ -125,6 +129,14 @@ export const ChangeRole = ({ memberId, currentRole, userEmail }: Props) => {
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
{customRoles?.map((customRole) => (
|
||||
<SelectItem
|
||||
key={customRole.role}
|
||||
value={customRole.role}
|
||||
>
|
||||
{customRole.role}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
@@ -132,6 +144,13 @@ export const ChangeRole = ({ memberId, currentRole, userEmail }: Props) => {
|
||||
<br />
|
||||
<strong>Member:</strong> Limited permissions, can be
|
||||
customized.
|
||||
{customRoles && customRoles.length > 0 && (
|
||||
<>
|
||||
<br />
|
||||
<strong>Custom roles:</strong> Enterprise-defined
|
||||
permissions.
|
||||
</>
|
||||
)}
|
||||
<br />
|
||||
<em className="text-muted-foreground text-xs">
|
||||
Note: Owner role is intransferible.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { format } from "date-fns";
|
||||
import { Loader2, MoreHorizontal, Users } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -35,9 +36,19 @@ export const ShowUsers = () => {
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data, isPending, refetch } = api.user.all.useQuery();
|
||||
const { mutateAsync } = api.user.remove.useMutation();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const { data: hasValidLicense } =
|
||||
api.licenseKey.haveValidLicenseKey.useQuery();
|
||||
|
||||
const utils = api.useUtils();
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: session } = api.user.session.useQuery();
|
||||
|
||||
const FREE_ROLES = ["owner", "admin", "member"];
|
||||
const membersWithCustomRoles = data?.filter(
|
||||
(member) => !FREE_ROLES.includes(member.role),
|
||||
);
|
||||
const hasCustomRolesWithoutLicense =
|
||||
!hasValidLicense && (membersWithCustomRoles?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -69,6 +80,18 @@ export const ShowUsers = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
{hasCustomRolesWithoutLicense && (
|
||||
<AlertBlock type="warning">
|
||||
You have{" "}
|
||||
{membersWithCustomRoles?.length === 1
|
||||
? "1 user"
|
||||
: `${membersWithCustomRoles?.length} users`}{" "}
|
||||
assigned to custom roles. Custom roles will not work
|
||||
without a valid Enterprise license. Please activate your
|
||||
license or change these users to a free role (Admin or
|
||||
Member).
|
||||
</AlertBlock>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -89,40 +112,39 @@ export const ShowUsers = () => {
|
||||
)?.role;
|
||||
|
||||
// Owner never has "Edit Permissions" (they're absolute owner)
|
||||
// Other users can edit permissions if target is not themselves and target is a member
|
||||
// Other users can edit permissions if target is not themselves and target is a member/custom role
|
||||
const isStaticAdminOrOwner =
|
||||
member.role === "owner" || member.role === "admin";
|
||||
const canEditPermissions =
|
||||
member.role !== "owner" &&
|
||||
member.role === "member" &&
|
||||
!isStaticAdminOrOwner &&
|
||||
member.user.id !== session?.user?.id;
|
||||
|
||||
// Can change role based on hierarchy:
|
||||
// - Owner: Can change anyone's role (except themselves and other owners)
|
||||
// - Admin: Can only change member roles (not other admins or owners)
|
||||
// - Admin: Can only change member/custom roles (not other admins or owners)
|
||||
// - Owner role is intransferible
|
||||
const canChangeRole =
|
||||
member.role !== "owner" &&
|
||||
member.user.id !== session?.user?.id &&
|
||||
(currentUserRole === "owner" ||
|
||||
(currentUserRole === "admin" &&
|
||||
member.role === "member"));
|
||||
member.role !== "admin"));
|
||||
|
||||
// Delete/Unlink follow same hierarchy as role changes
|
||||
// - Owner: Can delete/unlink anyone (except themselves and owner can't be deleted)
|
||||
// - Admin: Can only delete/unlink members (not other admins or owner)
|
||||
const canDelete =
|
||||
member.role !== "owner" &&
|
||||
!isCloud &&
|
||||
member.user.id !== session?.user?.id &&
|
||||
(currentUserRole === "owner" ||
|
||||
(currentUserRole === "admin" &&
|
||||
member.role === "member"));
|
||||
const canDeleteMember =
|
||||
permissions?.member.delete ?? false;
|
||||
|
||||
const canUnlink =
|
||||
// Self-hosted: "Delete User" removes the user entirely
|
||||
// Cloud: "Unlink User" removes from the organization only
|
||||
const canRemove =
|
||||
member.role !== "owner" &&
|
||||
member.user.id !== session?.user?.id &&
|
||||
(currentUserRole === "owner" ||
|
||||
(currentUserRole === "admin" &&
|
||||
member.role === "member"));
|
||||
member.role !== "admin") ||
|
||||
(canDeleteMember && !isStaticAdminOrOwner));
|
||||
|
||||
const canDelete = canRemove && !isCloud;
|
||||
const canUnlink = canRemove && !!isCloud;
|
||||
|
||||
const hasAnyAction =
|
||||
canEditPermissions ||
|
||||
@@ -134,6 +156,11 @@ export const ShowUsers = () => {
|
||||
<TableRow key={member.id}>
|
||||
<TableCell className="w-[100px]">
|
||||
{member.user.email}
|
||||
{member.user.id === session?.user?.id && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
(You)
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
@@ -179,9 +206,7 @@ export const ShowUsers = () => {
|
||||
{canChangeRole && (
|
||||
<ChangeRole
|
||||
memberId={member.id}
|
||||
currentRole={
|
||||
member.role as "admin" | "member"
|
||||
}
|
||||
currentRole={member.role}
|
||||
userEmail={member.user.email}
|
||||
/>
|
||||
)}
|
||||
@@ -189,6 +214,7 @@ export const ShowUsers = () => {
|
||||
{canEditPermissions && (
|
||||
<AddUserPermissions
|
||||
userId={member.user.id}
|
||||
role={member.role}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { HardDriveDownload, Loader2 } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
HardDriveDownload,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -15,11 +22,70 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
type ServiceStatus = {
|
||||
status: "healthy" | "unhealthy";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type HealthResult = {
|
||||
postgres: ServiceStatus;
|
||||
redis: ServiceStatus;
|
||||
traefik: ServiceStatus;
|
||||
};
|
||||
|
||||
type ModalState = "idle" | "checking" | "results" | "updating";
|
||||
|
||||
const ServiceStatusItem = ({
|
||||
name,
|
||||
service,
|
||||
}: {
|
||||
name: string;
|
||||
service: ServiceStatus;
|
||||
}) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{service.status === "healthy" ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm font-medium">{name}</span>
|
||||
{service.status === "unhealthy" && service.message && (
|
||||
<span className="text-xs text-muted-foreground">— {service.message}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const UpdateWebServer = () => {
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [modalState, setModalState] = useState<ModalState>("idle");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [healthResult, setHealthResult] = useState<HealthResult | null>(null);
|
||||
|
||||
const { mutateAsync: updateServer } = api.settings.updateServer.useMutation();
|
||||
const { refetch: checkHealth } =
|
||||
api.settings.checkInfrastructureHealth.useQuery(undefined, {
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const handleVerify = async () => {
|
||||
setModalState("checking");
|
||||
setHealthResult(null);
|
||||
|
||||
try {
|
||||
const result = await checkHealth();
|
||||
if (result.data) {
|
||||
setHealthResult(result.data);
|
||||
}
|
||||
} catch {
|
||||
// checkHealth failed entirely
|
||||
}
|
||||
setModalState("results");
|
||||
};
|
||||
|
||||
const allHealthy =
|
||||
healthResult &&
|
||||
healthResult.postgres.status === "healthy" &&
|
||||
healthResult.redis.status === "healthy" &&
|
||||
healthResult.traefik.status === "healthy";
|
||||
|
||||
const checkIsUpdateFinished = async () => {
|
||||
try {
|
||||
@@ -33,28 +99,24 @@ export const UpdateWebServer = () => {
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
// Allow seeing the toast before reloading
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} catch {
|
||||
// Delay each request
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
// Keep running until it returns 200
|
||||
void checkIsUpdateFinished();
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
setUpdating(true);
|
||||
setModalState("updating");
|
||||
await updateServer();
|
||||
|
||||
// Give some time for docker service restart before starting to check status
|
||||
await new Promise((resolve) => setTimeout(resolve, 8000));
|
||||
|
||||
await checkIsUpdateFinished();
|
||||
} catch (error) {
|
||||
setUpdating(false);
|
||||
setModalState("results");
|
||||
console.error("Error updating server:", error);
|
||||
toast.error(
|
||||
"An error occurred while updating the server, please try again.",
|
||||
@@ -62,6 +124,14 @@ export const UpdateWebServer = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (modalState !== "updating") {
|
||||
setOpen(false);
|
||||
setModalState("idle");
|
||||
setHealthResult(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open}>
|
||||
<AlertDialogTrigger asChild>
|
||||
@@ -81,36 +151,111 @@ export const UpdateWebServer = () => {
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{updating
|
||||
? "Server update in progress"
|
||||
: "Are you absolutely sure?"}
|
||||
{modalState === "idle" && "Are you absolutely sure?"}
|
||||
{modalState === "checking" && "Verifying Services..."}
|
||||
{modalState === "results" &&
|
||||
(allHealthy ? "Ready to Update" : "Service Issues Detected")}
|
||||
{modalState === "updating" && "Server update in progress"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{updating ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Loader2 className="animate-spin" />
|
||||
The server is being updated, please wait...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
This action cannot be undone. This will update the web server to
|
||||
the new version. You will not be able to use the panel during
|
||||
the update process. The page will be reloaded once the update is
|
||||
finished.
|
||||
</>
|
||||
)}
|
||||
<AlertDialogDescription asChild>
|
||||
<div>
|
||||
{modalState === "idle" && (
|
||||
<span>
|
||||
This will update the web server to the new version. You will
|
||||
not be able to use the panel during the update process. The
|
||||
page will be reloaded once the update is finished.
|
||||
<br />
|
||||
<br />
|
||||
We recommend verifying that all services are running before
|
||||
updating.
|
||||
</span>
|
||||
)}
|
||||
|
||||
{modalState === "checking" && (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="animate-spin h-4 w-4" />
|
||||
Checking PostgreSQL, Redis and Traefik...
|
||||
</span>
|
||||
)}
|
||||
|
||||
{modalState === "results" && healthResult && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<ServiceStatusItem
|
||||
name="PostgreSQL"
|
||||
service={healthResult.postgres}
|
||||
/>
|
||||
<ServiceStatusItem
|
||||
name="Redis"
|
||||
service={healthResult.redis}
|
||||
/>
|
||||
<ServiceStatusItem
|
||||
name="Traefik"
|
||||
service={healthResult.traefik}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!allHealthy && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 p-3">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500 mt-0.5 shrink-0" />
|
||||
<span className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
Some services are not healthy. You can still proceed
|
||||
with the update.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allHealthy && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
All services are running. You can proceed with the update.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modalState === "results" && !healthResult && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 p-3">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500 mt-0.5 shrink-0" />
|
||||
<span className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
Could not verify services. You can still proceed with the
|
||||
update.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modalState === "updating" && (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="animate-spin h-4 w-4" />
|
||||
The server is being updated, please wait...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{!updating && (
|
||||
{modalState === "idle" && (
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogCancel onClick={handleClose}>Cancel</AlertDialogCancel>
|
||||
<Button variant="secondary" onClick={handleVerify}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Verify Status
|
||||
</Button>
|
||||
<AlertDialogAction onClick={handleConfirm}>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
)}
|
||||
{modalState === "results" && (
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleClose}>Cancel</AlertDialogCancel>
|
||||
<Button variant="secondary" onClick={handleVerify}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Re-check
|
||||
</Button>
|
||||
<AlertDialogAction onClick={handleConfirm}>
|
||||
{allHealthy ? "Confirm" : "Confirm Anyway"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
)}
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
|
||||
@@ -17,17 +17,18 @@ import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: "postgres" | "mysql" | "mariadb" | "mongo" | "redis";
|
||||
type: "libsql" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis";
|
||||
}
|
||||
|
||||
export const RebuildDatabase = ({ id, type }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
|
||||
const mutationMap = {
|
||||
postgres: () => api.postgres.rebuild.useMutation(),
|
||||
mysql: () => api.mysql.rebuild.useMutation(),
|
||||
libsql: () => api.libsql.rebuild.useMutation(),
|
||||
mariadb: () => api.mariadb.rebuild.useMutation(),
|
||||
mongo: () => api.mongo.rebuild.useMutation(),
|
||||
mysql: () => api.mysql.rebuild.useMutation(),
|
||||
postgres: () => api.postgres.rebuild.useMutation(),
|
||||
redis: () => api.redis.rebuild.useMutation(),
|
||||
};
|
||||
|
||||
@@ -36,10 +37,11 @@ export const RebuildDatabase = ({ id, type }: Props) => {
|
||||
const handleRebuild = async () => {
|
||||
try {
|
||||
await mutateAsync({
|
||||
postgresId: type === "postgres" ? id : "",
|
||||
mysqlId: type === "mysql" ? id : "",
|
||||
libsqlId: type === "libsql" ? id : "",
|
||||
mariadbId: type === "mariadb" ? id : "",
|
||||
mongoId: type === "mongo" ? id : "",
|
||||
mysqlId: type === "mysql" ? id : "",
|
||||
postgresId: type === "postgres" ? id : "",
|
||||
redisId: type === "redis" ? id : "",
|
||||
});
|
||||
toast.success("Database rebuilt successfully");
|
||||
|
||||
@@ -6,14 +6,20 @@ import { RebuildDatabase } from "./rebuild-database";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: "postgres" | "mysql" | "mariadb" | "mongo" | "redis";
|
||||
type: "libsql" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis";
|
||||
}
|
||||
|
||||
export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5">
|
||||
<ShowCustomCommand id={id} type={type} />
|
||||
<ShowClusterSettings id={id} type={type} />
|
||||
{type === "mariadb" ||
|
||||
type === "mongo" ||
|
||||
type === "mysql" ||
|
||||
type === "postgres" ||
|
||||
type === "redis" ? (
|
||||
<ShowClusterSettings id={id} type={type} />
|
||||
) : null}
|
||||
<ShowVolumes id={id} type={type} />
|
||||
<ShowResources id={id} type={type} />
|
||||
<RebuildDatabase id={id} type={type} />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user