mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-17 13:15:23 +02:00
Compare commits
121 Commits
v0.28.8
...
dosu/doc-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39c771ea6b | ||
|
|
290a03ccfb | ||
|
|
63aa60f7e2 | ||
|
|
fe9b0ebcea | ||
|
|
8ccdb66ced | ||
|
|
e38f07d286 | ||
|
|
035d39e3b7 | ||
|
|
82a908a865 | ||
|
|
4bbb2ece49 | ||
|
|
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 | ||
|
|
ee42a393aa | ||
|
|
5e6e5ba9d8 | ||
|
|
1203d0589b | ||
|
|
653e5fa3a0 | ||
|
|
66931fe24f | ||
|
|
7feb4061f8 | ||
|
|
c75cfa2d69 | ||
|
|
1c5b92729a | ||
|
|
86feda1679 | ||
|
|
f95b29a450 | ||
|
|
a1cf5520a9 | ||
|
|
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 |
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,8 +19,8 @@ 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.
|
||||
- **Backups**: Automate backups for databases to an external storage destination.
|
||||
- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, libsql, and Redis.
|
||||
- **Backups**: Automate backups for databases and volumes to external storage destinations (S3, SFTP, FTP, Google Drive).
|
||||
- **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.
|
||||
- **Templates**: Deploy open-source templates (Plausible, Pocketbase, Calcom, etc.) with a single click.
|
||||
|
||||
@@ -35,6 +35,7 @@ const ENTERPRISE_RESOURCES = [
|
||||
"domain",
|
||||
"destination",
|
||||
"notification",
|
||||
"tag",
|
||||
"logs",
|
||||
"monitoring",
|
||||
"auditLog",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -29,23 +29,25 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
||||
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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -39,15 +39,16 @@ 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]()
|
||||
@@ -55,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]()
|
||||
@@ -87,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 () => {
|
||||
|
||||
@@ -71,6 +71,7 @@ const formSchema = z
|
||||
"mongo",
|
||||
"mysql",
|
||||
"redis",
|
||||
"libsql",
|
||||
]),
|
||||
serviceName: z.string(),
|
||||
destinationId: z.string().min(1, "Destination required"),
|
||||
|
||||
@@ -57,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 }),
|
||||
@@ -72,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(),
|
||||
@@ -98,6 +100,7 @@ export const DeleteService = ({ id, type }: Props) => {
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
libsqlId: id || "",
|
||||
applicationId: id || "",
|
||||
composeId: id || "",
|
||||
deleteVolumes,
|
||||
|
||||
@@ -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,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 ?? undefined}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 />
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -92,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("");
|
||||
|
||||
@@ -28,13 +28,14 @@ export type Services = {
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -15,6 +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 { TagBadge } from "@/components/shared/tag-badge";
|
||||
import { TagFilter } from "@/components/shared/tag-filter";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -49,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";
|
||||
@@ -63,6 +64,7 @@ export const ShowProjects = () => {
|
||||
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 : "",
|
||||
@@ -76,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 : "";
|
||||
@@ -107,7 +130,7 @@ export const ShowProjects = () => {
|
||||
const filteredProjects = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
const filtered = data.filter(
|
||||
let filtered = data.filter(
|
||||
(project) =>
|
||||
project.name
|
||||
.toLowerCase()
|
||||
@@ -117,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) => {
|
||||
@@ -162,7 +194,7 @@ export const ShowProjects = () => {
|
||||
}
|
||||
return direction === "asc" ? comparison : -comparison;
|
||||
});
|
||||
}, [data, debouncedSearchQuery, sortBy]);
|
||||
}, [data, debouncedSearchQuery, sortBy, selectedTagIds]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -208,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 && (
|
||||
@@ -247,26 +294,27 @@ 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);
|
||||
|
||||
@@ -309,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" />
|
||||
@@ -429,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>
|
||||
|
||||
@@ -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++];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { PenBoxIcon, PlusIcon, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
@@ -35,6 +35,10 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
ADDITIONAL_FLAG_ERROR,
|
||||
ADDITIONAL_FLAG_REGEX,
|
||||
} from "@dokploy/server/db/validations/destination";
|
||||
import { S3_PROVIDERS } from "./constants";
|
||||
|
||||
const addDestination = z.object({
|
||||
@@ -46,6 +50,16 @@ const addDestination = z.object({
|
||||
region: z.string(),
|
||||
endpoint: z.string().min(1, "Endpoint is required"),
|
||||
serverId: z.string().optional(),
|
||||
additionalFlags: z
|
||||
.array(
|
||||
z.object({
|
||||
value: z
|
||||
.string()
|
||||
.min(1, "Flag cannot be empty")
|
||||
.regex(ADDITIONAL_FLAG_REGEX, ADDITIONAL_FLAG_ERROR),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type AddDestination = z.infer<typeof addDestination>;
|
||||
@@ -89,9 +103,16 @@ export const HandleDestinations = ({ destinationId }: Props) => {
|
||||
region: "",
|
||||
secretAccessKey: "",
|
||||
endpoint: "",
|
||||
additionalFlags: [],
|
||||
},
|
||||
resolver: zodResolver(addDestination),
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "additionalFlags",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (destination) {
|
||||
form.reset({
|
||||
@@ -102,6 +123,8 @@ export const HandleDestinations = ({ destinationId }: Props) => {
|
||||
bucket: destination.bucket,
|
||||
region: destination.region,
|
||||
endpoint: destination.endpoint,
|
||||
additionalFlags:
|
||||
destination.additionalFlags?.map((f) => ({ value: f })) ?? [],
|
||||
});
|
||||
} else {
|
||||
form.reset();
|
||||
@@ -118,6 +141,7 @@ export const HandleDestinations = ({ destinationId }: Props) => {
|
||||
region: data.region,
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
destinationId: destinationId || "",
|
||||
additionalFlags: data.additionalFlags?.map((f) => f.value) ?? [],
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success(`Destination ${destinationId ? "Updated" : "Created"}`);
|
||||
@@ -127,9 +151,12 @@ export const HandleDestinations = ({ destinationId }: Props) => {
|
||||
}
|
||||
setOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((e) => {
|
||||
toast.error(
|
||||
`Error ${destinationId ? "Updating" : "Creating"} the Destination`,
|
||||
{
|
||||
description: e.message,
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -141,6 +168,7 @@ export const HandleDestinations = ({ destinationId }: Props) => {
|
||||
"secretAccessKey",
|
||||
"bucket",
|
||||
"endpoint",
|
||||
"additionalFlags",
|
||||
]);
|
||||
|
||||
if (!result) {
|
||||
@@ -179,6 +207,8 @@ export const HandleDestinations = ({ destinationId }: Props) => {
|
||||
region,
|
||||
secretAccessKey: secretKey,
|
||||
serverId,
|
||||
additionalFlags:
|
||||
form.getValues("additionalFlags")?.map((f) => f.value) ?? [],
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Connection Success");
|
||||
@@ -358,6 +388,48 @@ export const HandleDestinations = ({ destinationId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Additional Flags (Optional)</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => append({ value: "" })}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Add Flag
|
||||
</Button>
|
||||
</div>
|
||||
{fields.map((field, index) => (
|
||||
<FormField
|
||||
key={field.id}
|
||||
control={form.control}
|
||||
name={`additionalFlags.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="--s3-sign-accept-encoding=false"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter
|
||||
|
||||
@@ -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,
|
||||
@@ -1406,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
|
||||
@@ -1492,6 +1598,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === "lark" && (
|
||||
<>
|
||||
<FormField
|
||||
@@ -1852,6 +1959,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
isLoadingResend ||
|
||||
isLoadingGotify ||
|
||||
isLoadingNtfy ||
|
||||
isLoadingMattermost ||
|
||||
isLoadingLark ||
|
||||
isLoadingTeams ||
|
||||
isLoadingCustom ||
|
||||
@@ -1911,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,
|
||||
@@ -121,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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -46,7 +46,8 @@ export type Services = {
|
||||
| "mysql"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "compose";
|
||||
| "compose"
|
||||
| "libsql";
|
||||
description?: string | null;
|
||||
id: string;
|
||||
createdAt: string;
|
||||
@@ -136,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,
|
||||
@@ -143,6 +156,7 @@ export const extractServices = (data: Environment | undefined) => {
|
||||
...postgres,
|
||||
...mariadb,
|
||||
...compose,
|
||||
...libsql,
|
||||
);
|
||||
|
||||
applications.sort((a, b) => {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -156,6 +156,61 @@ export const RedisIcon = ({ className }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const LibsqlIcon = ({ className }: Props) => {
|
||||
return (
|
||||
<svg
|
||||
aria-label="libsql"
|
||||
height="1em"
|
||||
width="1em"
|
||||
viewBox="0 0 217.2 217.2"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
style={{ fill: "#72f5cf", strokeWidth: "0px" }}
|
||||
d="M118,87.2c.7,0,1.3,0,1.9.3.4.1.8.3,1.2.5.2.1.4.2.6.3.4.3.7.5,1.1.9l5.2,5.2c2.7,2.7,2.7,7,0,9.7l-95.1,95.1c-1.3,1.3-3.1,2-4.8,2s-3.5-.7-4.8-2l-5.2-5.2c-2.7-2.7-2.7-7,0-9.7l85.1-85.1,10-10c.3-.3.7-.6,1.1-.9.2-.1.4-.2.6-.3.4-.2.8-.4,1.2-.5.6-.2,1.3-.3,1.9-.3M118,71.2c-2.2,0-4.4.3-6.5.9-1.4.4-2.8,1-4.1,1.7-.7.4-1.3.7-2,1.2-1.3.8-2.5,1.8-3.6,2.9l-10,10L6.7,173c-8.9,8.9-8.9,23.4,0,32.3l5.2,5.2c4.3,4.3,10.1,6.7,16.2,6.7s11.8-2.4,16.2-6.7l95.1-95.1c4.3-4.3,6.7-10.1,6.7-16.2s-2.4-11.8-6.7-16.2l-5.2-5.2c-1.1-1.1-2.3-2.1-3.6-2.9-.6-.4-1.3-.8-1.9-1.2-1.3-.7-2.7-1.3-4.1-1.7-2.1-.6-4.3-.9-6.5-.9h0Z"
|
||||
/>
|
||||
<g>
|
||||
<path
|
||||
style={{ fill: "#72f5cf", strokeWidth: "0px" }}
|
||||
d="M119.4,16c.3,0,.6,0,.9,0l66.5,3.8c5.8.3,10.3,4.9,10.7,10.7l3.8,66.5c.3,4.4-1.4,8.7-4.5,11.8l-79.7,79.7c-3,3-6.9,4.5-11,4.5s-3.3-.3-4.9-.8l-49.9-16.5c-4.7-1.5-8.3-5.2-9.8-9.8l-16.5-49.9c-1.8-5.6-.4-11.7,3.8-15.8L108.4,20.5c2.9-2.9,6.9-4.5,11-4.5M119.4,0c-8.4,0-16.3,3.3-22.3,9.2L17.4,88.9c-8.5,8.5-11.4,20.8-7.6,32.1l16.5,49.9c3.1,9.4,10.6,16.9,20,20l49.9,16.5c3.2,1.1,6.5,1.6,9.9,1.6,8.4,0,16.3-3.3,22.3-9.2l79.7-79.7c6.3-6.3,9.7-15.1,9.2-24.1l-3.8-66.5c-.8-13.9-11.9-24.9-25.7-25.7L121.2,0c-.6,0-1.2,0-1.8,0h0Z"
|
||||
/>
|
||||
<path
|
||||
style={{ fill: "#a8f7d9", strokeWidth: "0px" }}
|
||||
d="M24.9,116.1l16.5,49.9c1.5,4.7,5.2,8.3,9.8,9.8l49.9,16.5c5.6,1.8,11.7.4,15.8-3.8l79.7-79.7c3.1-3.1,4.8-7.4,4.5-11.8l-3.8-66.5c-.3-5.8-4.9-10.3-10.7-10.7l-66.5-3.8c-4.4-.3-8.7,1.4-11.8,4.5L28.7,100.3c-4.1,4.1-5.6,10.3-3.8,15.8Z"
|
||||
/>
|
||||
<path
|
||||
style={{ fill: "#141b1f", strokeWidth: "0px" }}
|
||||
d="M119.4,16c.3,0,.6,0,.9,0l66.5,3.8c5.8.3,10.3,4.9,10.7,10.7l3.8,66.5c.3,4.4-1.4,8.7-4.5,11.8l-79.7,79.7c-3,3-6.9,4.5-11,4.5s-3.3-.3-4.9-.8l-49.9-16.5c-4.7-1.5-8.3-5.2-9.8-9.8l-16.5-49.9c-1.8-5.6-.4-11.7,3.8-15.8L108.4,20.5c2.9-2.9,6.9-4.5,11-4.5M119.4,6c-6.8,0-13.2,2.7-18,7.5L21.6,93.2c-6.8,6.8-9.2,16.8-6.2,26l16.5,49.9c2.5,7.6,8.6,13.7,16.2,16.2l49.9,16.5c2.6.9,5.3,1.3,8,1.3,6.8,0,13.2-2.7,18-7.5l79.7-79.7c5.1-5.1,7.8-12.2,7.4-19.5l-3.8-66.5c-.6-10.8-9.3-19.5-20.1-20.1l-66.5-3.8c-.5,0-1,0-1.5,0h0Z"
|
||||
/>
|
||||
<path
|
||||
style={{ fill: "#141b1f", strokeWidth: "0px" }}
|
||||
d="M136.7,173.7l6.9-6.9c-.2-.1-.4-.2-.6-.3l-27.6-9.1-6.9,6.9,28.3,9.3Z"
|
||||
/>
|
||||
<path
|
||||
style={{ fill: "#141b1f", strokeWidth: "0px" }}
|
||||
d="M166.5,143.9l6.9-6.9c-.2-.1-.4-.2-.6-.3l-27.6-9.1-6.9,6.9,28.3,9.3Z"
|
||||
/>
|
||||
<path
|
||||
style={{ fill: "#141b1f", strokeWidth: "0px" }}
|
||||
d="M43.5,80.5l6.9-6.9c.1.2.2.4.3.6l9.1,27.6-6.9,6.9-9.3-28.3Z"
|
||||
/>
|
||||
<path
|
||||
style={{ fill: "#141b1f", strokeWidth: "0px" }}
|
||||
d="M73.3,50.7l6.9-6.9c.1.2.2.4.3.6l9.1,27.6-6.9,6.9-9.3-28.3Z"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
style={{ fill: "#79ac91", strokeWidth: "0px" }}
|
||||
d="M130.6,101.5l-97.7,97.7c-2.7,2.7-7,2.7-9.7,0l-5.2-5.2c-2.7-2.7-2.7-7,0-9.7l97.7-97.7c1.5-1.5,3.4-2.4,5.5-2.6l7.9-.8c2.8-.3,5.1,2.1,4.9,4.9l-.8,7.9c-.2,2.1-1.1,4-2.6,5.5Z"
|
||||
/>
|
||||
<path
|
||||
style={{ fill: "#141b1f", strokeWidth: "0px" }}
|
||||
d="M129.5,83.3c2.6,0,4.7,2.2,4.4,4.9l-.8,7.9c-.2,2.1-1.1,4-2.6,5.5l-97.7,97.7c-1.3,1.3-3.1,2-4.8,2s-3.5-.7-4.8-2l-5.2-5.2c-2.7-2.7-2.7-7,0-9.7l97.7-97.7c1.5-1.5,3.4-2.4,5.5-2.6l7.9-.8c.1,0,.3,0,.4,0M129.5,73.3h0c-.5,0-.9,0-1.4,0l-7.9.8c-4.4.4-8.5,2.4-11.5,5.5L10.9,177.3c-6.6,6.6-6.6,17.3,0,23.8l5.2,5.2c3.2,3.2,7.4,4.9,11.9,4.9s8.7-1.8,11.9-4.9l97.7-97.7c3.1-3.1,5-7.2,5.5-11.5l.8-7.9c.4-4-.9-8.1-3.7-11.1-2.7-3-6.6-4.7-10.7-4.7h0Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const GitlabIcon = ({ className }: Props) => {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -88,6 +88,21 @@ export const DiscordIcon = ({ className }: Props) => {
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const MattermostIcon = ({ className }: Props) => {
|
||||
return (
|
||||
<svg
|
||||
fill="#0061ff"
|
||||
viewBox="0 0 501 501"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn("size-8", className)}
|
||||
>
|
||||
<path d="M236 .7C137.7 7.5 54 68.2 18.2 158.5c-32 81-19.6 172.8 33 242.5 39.8 53 97.2 87 164.3 97 16.5 2.7 48 3.2 63.5 1.2 48.7-6.3 92.2-24.6 129-54.2 13-10.5 33-31.2 42.2-43.7 26.4-35.5 42.8-75.8 49-120.3 1.6-12.3 1.6-48.7 0-61-4-28.3-12-54.8-24.2-79.5-12.8-26-26.5-45.3-46.8-65.8C417.8 64 400.2 49 398.4 49c-.6 0-.4 10.5.3 26l1.3 26 7 8.7c19 23.7 32.8 53.5 38.2 83 2.5 14 3 43 1 55.8-4.5 27.8-15.2 54-31 76.5-8.6 12.2-28 31.6-40.2 40.2-24 17-50 27.6-80 33-10 1.8-49 1.8-59 0-43-7.7-78.8-26-107.2-54.8-29.3-29.7-46.5-64-52.4-104.4-2-14-1.5-42 1-55C90 121.4 132 72 192 49.7c8-3 18.4-5.8 29.5-8.2 1.7-.4 34.4-38 35.3-40.6.3-1-10.2-1-20.8-.4z" />
|
||||
<path d="M322.2 24.6c-1.3.8-8.4 9.3-16 18.7-7.4 9.5-22.4 28-33.2 41.2-51 62.2-66 81.6-70.6 91-6 12-8.4 21-9 33-1.2 19.8 5 36 19 50C222 268 230 273 243 277.2c9 3 10.4 3.2 24 3.2 13.8 0 15 0 22.6-3 23.2-9 39-28.4 45-55.7 2-8.2 2-28.7.4-79.7l-2-72c-1-36.8-1.4-41.8-3-44-2-3-4.8-3.6-7.8-1.4z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const TeamsIcon = ({ className }: Props) => {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Star,
|
||||
Tags,
|
||||
Trash2,
|
||||
User,
|
||||
Users,
|
||||
@@ -325,6 +326,13 @@ const MENU: Menu = {
|
||||
isSingle: true,
|
||||
isEnabled: ({ permissions }) => !!permissions?.organization.update,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Tags",
|
||||
url: "/dashboard/settings/tags",
|
||||
icon: Tags,
|
||||
isEnabled: ({ permissions }) => !!permissions?.tag.read,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Git",
|
||||
@@ -908,6 +916,7 @@ export default function Page({ children }: Props) {
|
||||
onOpenChange={(open) => {
|
||||
setDefaultOpen(open);
|
||||
|
||||
// biome-ignore lint/suspicious/noDocumentCookie: this sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}`;
|
||||
}}
|
||||
style={
|
||||
|
||||
@@ -143,6 +143,10 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
|
||||
description:
|
||||
"Manage notification providers (Slack, Discord, Telegram, etc.)",
|
||||
},
|
||||
tag: {
|
||||
label: "Tags",
|
||||
description: "Manage tags to organize and categorize projects",
|
||||
},
|
||||
member: {
|
||||
label: "Users",
|
||||
description: "Manage organization members, invitations, and roles",
|
||||
@@ -379,6 +383,12 @@ const ACTION_META: Record<
|
||||
},
|
||||
delete: { label: "Delete", description: "Remove notification providers" },
|
||||
},
|
||||
tag: {
|
||||
read: { label: "Read", description: "View tags" },
|
||||
create: { label: "Create", description: "Create new tags" },
|
||||
update: { label: "Update", description: "Edit existing tags" },
|
||||
delete: { label: "Delete", description: "Delete tags" },
|
||||
},
|
||||
member: {
|
||||
read: {
|
||||
label: "Read",
|
||||
@@ -447,6 +457,7 @@ const ROLE_PRESETS: {
|
||||
domain: ["read"],
|
||||
destination: ["read"],
|
||||
notification: ["read"],
|
||||
tag: ["read"],
|
||||
member: ["read"],
|
||||
logs: ["read"],
|
||||
monitoring: ["read"],
|
||||
@@ -515,6 +526,7 @@ const ROLE_PRESETS: {
|
||||
domain: ["read", "create", "delete"],
|
||||
destination: ["read", "create", "delete"],
|
||||
notification: ["read", "create", "delete"],
|
||||
tag: ["read", "create", "update", "delete"],
|
||||
logs: ["read"],
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
|
||||
635
apps/dokploy/components/shared/advance-breadcrumb.tsx
Normal file
635
apps/dokploy/components/shared/advance-breadcrumb.tsx
Normal file
@@ -0,0 +1,635 @@
|
||||
import type { ServiceType } from "@dokploy/server/db/schema";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircuitBoard,
|
||||
FolderInput,
|
||||
GlobeIcon,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { type ComponentType, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
LibsqlIcon,
|
||||
MariadbIcon,
|
||||
MongodbIcon,
|
||||
MysqlIcon,
|
||||
PostgresqlIcon,
|
||||
RedisIcon,
|
||||
} from "@/components/icons/data-tools-icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { api, type RouterOutputs } from "@/utils/api";
|
||||
|
||||
type ProjectItem = RouterOutputs["project"]["all"][number];
|
||||
type ProjectEnvironment = ProjectItem["environments"][number];
|
||||
type EnvironmentDetails = RouterOutputs["environment"]["one"];
|
||||
|
||||
type ServiceItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ServiceType;
|
||||
};
|
||||
|
||||
type NamedService = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
type EnvironmentServiceCollections = {
|
||||
applications: (NamedService & { applicationId: string })[];
|
||||
compose: (NamedService & { composeId: string })[];
|
||||
postgres: (NamedService & { postgresId: string })[];
|
||||
mysql: (NamedService & { mysqlId: string })[];
|
||||
mariadb: (NamedService & { mariadbId: string })[];
|
||||
redis: (NamedService & { redisId: string })[];
|
||||
mongo: (NamedService & { mongoId: string })[];
|
||||
libsql: (NamedService & { libsqlId: string })[];
|
||||
};
|
||||
|
||||
type ServiceCollections = Pick<
|
||||
ProjectEnvironment,
|
||||
| "applications"
|
||||
| "compose"
|
||||
| "postgres"
|
||||
| "mysql"
|
||||
| "mariadb"
|
||||
| "redis"
|
||||
| "mongo"
|
||||
| "libsql"
|
||||
>;
|
||||
|
||||
const SERVICE_COLLECTION_KEYS = [
|
||||
"applications",
|
||||
"compose",
|
||||
"postgres",
|
||||
"mysql",
|
||||
"mariadb",
|
||||
"redis",
|
||||
"mongo",
|
||||
"libsql",
|
||||
] as const satisfies ReadonlyArray<keyof ServiceCollections>;
|
||||
|
||||
const SERVICE_QUERY_KEYS = [
|
||||
"applicationId",
|
||||
"composeId",
|
||||
"postgresId",
|
||||
"mysqlId",
|
||||
"mariadbId",
|
||||
"redisId",
|
||||
"mongoId",
|
||||
"libsqlId",
|
||||
] as const;
|
||||
|
||||
const SERVICE_ICONS: Record<
|
||||
ServiceType,
|
||||
ComponentType<{ className?: string }>
|
||||
> = {
|
||||
application: GlobeIcon,
|
||||
compose: CircuitBoard,
|
||||
postgres: PostgresqlIcon,
|
||||
mysql: MysqlIcon,
|
||||
mariadb: MariadbIcon,
|
||||
redis: RedisIcon,
|
||||
mongo: MongodbIcon,
|
||||
libsql: LibsqlIcon,
|
||||
};
|
||||
|
||||
const getStringQueryParam = (value: string | string[] | undefined) =>
|
||||
typeof value === "string" ? value : null;
|
||||
|
||||
const includesSearch = (value: string | null | undefined, search: string) =>
|
||||
value?.toLowerCase().includes(search.toLowerCase()) ?? false;
|
||||
|
||||
const getServiceIcon = (type: ServiceType, className = "size-4") => {
|
||||
const Icon = SERVICE_ICONS[type];
|
||||
return <Icon className={className} />;
|
||||
};
|
||||
|
||||
const countEnvironmentServices = (environment: ServiceCollections): number =>
|
||||
SERVICE_COLLECTION_KEYS.reduce(
|
||||
(total, key) => total + environment[key].length,
|
||||
0,
|
||||
);
|
||||
|
||||
const mapServices = <T extends { name: string }>(
|
||||
items: readonly T[],
|
||||
getId: (item: T) => string,
|
||||
type: ServiceType,
|
||||
): ServiceItem[] =>
|
||||
items.map((item) => ({
|
||||
id: getId(item),
|
||||
name: item.name,
|
||||
type,
|
||||
}));
|
||||
|
||||
const extractServicesFromEnvironment = (
|
||||
environment: EnvironmentDetails | null | undefined,
|
||||
): ServiceItem[] => {
|
||||
if (!environment) return [];
|
||||
|
||||
const servicesByType =
|
||||
environment as unknown as EnvironmentServiceCollections;
|
||||
|
||||
return [
|
||||
...mapServices(
|
||||
servicesByType.applications,
|
||||
(item) => item.applicationId,
|
||||
"application",
|
||||
),
|
||||
...mapServices(servicesByType.compose, (item) => item.composeId, "compose"),
|
||||
...mapServices(
|
||||
servicesByType.postgres,
|
||||
(item) => item.postgresId,
|
||||
"postgres",
|
||||
),
|
||||
...mapServices(servicesByType.mysql, (item) => item.mysqlId, "mysql"),
|
||||
...mapServices(servicesByType.mariadb, (item) => item.mariadbId, "mariadb"),
|
||||
...mapServices(servicesByType.redis, (item) => item.redisId, "redis"),
|
||||
...mapServices(servicesByType.mongo, (item) => item.mongoId, "mongo"),
|
||||
...mapServices(servicesByType.libsql, (item) => item.libsqlId, "libsql"),
|
||||
];
|
||||
};
|
||||
|
||||
const getTargetEnvironmentId = (
|
||||
project: ProjectItem,
|
||||
selectedEnvironmentId?: string,
|
||||
) => {
|
||||
if (selectedEnvironmentId) return selectedEnvironmentId;
|
||||
|
||||
const productionEnvironment = project.environments.find(
|
||||
(environment) => environment.name === "production",
|
||||
);
|
||||
|
||||
return (
|
||||
productionEnvironment?.environmentId ??
|
||||
project.environments[0]?.environmentId
|
||||
);
|
||||
};
|
||||
|
||||
export const AdvanceBreadcrumb = () => {
|
||||
const router = useRouter();
|
||||
const { query } = router;
|
||||
|
||||
// Read IDs from URL (dynamic route segments)
|
||||
const projectId = getStringQueryParam(query.projectId);
|
||||
const environmentId = getStringQueryParam(query.environmentId);
|
||||
const serviceId =
|
||||
SERVICE_QUERY_KEYS.map((key) => getStringQueryParam(query[key])).find(
|
||||
(value): value is string => !!value,
|
||||
) ?? null;
|
||||
|
||||
const [projectOpen, setProjectOpen] = useState(false);
|
||||
const [serviceOpen, setServiceOpen] = useState(false);
|
||||
const [environmentOpen, setEnvironmentOpen] = useState(false);
|
||||
const [projectSearch, setProjectSearch] = useState("");
|
||||
const [serviceSearch, setServiceSearch] = useState("");
|
||||
const [environmentSearch, setEnvironmentSearch] = useState("");
|
||||
const [expandedProjectId, setExpandedProjectId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Fetch all projects
|
||||
const { data: allProjects } = api.project.all.useQuery();
|
||||
|
||||
// Fetch current project data
|
||||
const { data: currentProject } = api.project.one.useQuery(
|
||||
{ projectId: projectId ?? "" },
|
||||
{ enabled: !!projectId },
|
||||
);
|
||||
|
||||
// Fetch current environment
|
||||
const { data: currentEnvironment } = api.environment.one.useQuery(
|
||||
{ environmentId: environmentId ?? "" },
|
||||
{ enabled: !!environmentId },
|
||||
);
|
||||
|
||||
// Fetch environments for current project
|
||||
const { data: projectEnvironments } = api.environment.byProjectId.useQuery(
|
||||
{ projectId: projectId ?? "" },
|
||||
{ enabled: !!projectId },
|
||||
);
|
||||
|
||||
// Close dropdowns on escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setProjectOpen(false);
|
||||
setServiceOpen(false);
|
||||
setEnvironmentOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
const services = useMemo(
|
||||
() => extractServicesFromEnvironment(currentEnvironment),
|
||||
[currentEnvironment],
|
||||
);
|
||||
|
||||
const currentService = useMemo(
|
||||
() => services.find((service) => service.id === serviceId),
|
||||
[serviceId, services],
|
||||
);
|
||||
|
||||
// Navigate to project's default environment
|
||||
const handleProjectSelect = (
|
||||
selectedProjectId: string,
|
||||
selectedEnvironmentId?: string,
|
||||
) => {
|
||||
const project = allProjects?.find((p) => p.projectId === selectedProjectId);
|
||||
if (project) {
|
||||
const targetEnvironmentId = getTargetEnvironmentId(
|
||||
project,
|
||||
selectedEnvironmentId,
|
||||
);
|
||||
|
||||
if (targetEnvironmentId) {
|
||||
router.push(
|
||||
`/dashboard/project/${selectedProjectId}/environment/${targetEnvironmentId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
setProjectOpen(false);
|
||||
setExpandedProjectId(null);
|
||||
};
|
||||
|
||||
// Navigate to environment
|
||||
const handleEnvironmentSelect = (envId: string) => {
|
||||
router.push(`/dashboard/project/${projectId}/environment/${envId}`);
|
||||
setEnvironmentOpen(false);
|
||||
};
|
||||
|
||||
// Navigate to service
|
||||
const handleServiceSelect = (service: ServiceItem) => {
|
||||
if (!environmentId) return;
|
||||
|
||||
router.push(
|
||||
`/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}`,
|
||||
);
|
||||
setServiceOpen(false);
|
||||
};
|
||||
|
||||
const filteredProjects = useMemo(
|
||||
() =>
|
||||
(allProjects ?? []).filter(
|
||||
(project) =>
|
||||
includesSearch(project.name, projectSearch) ||
|
||||
includesSearch(project.description, projectSearch),
|
||||
),
|
||||
[allProjects, projectSearch],
|
||||
);
|
||||
|
||||
const filteredServices = useMemo(
|
||||
() =>
|
||||
services.filter((service) => includesSearch(service.name, serviceSearch)),
|
||||
[serviceSearch, services],
|
||||
);
|
||||
|
||||
const filteredEnvironments = useMemo(
|
||||
() =>
|
||||
(projectEnvironments ?? []).filter((environment) =>
|
||||
includesSearch(environment.name, environmentSearch),
|
||||
),
|
||||
[environmentSearch, projectEnvironments],
|
||||
);
|
||||
|
||||
// If we're just on the projects page, show simple breadcrumb
|
||||
if (!projectId) {
|
||||
return (
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderInput className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium">Projects</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
|
||||
<div className="flex items-center">
|
||||
{/* Project Selector */}
|
||||
<Popover open={projectOpen} onOpenChange={setProjectOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-expanded={projectOpen}
|
||||
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
||||
>
|
||||
<FolderInput className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium max-w-[150px] truncate">
|
||||
{currentProject?.name || "Select Project"}
|
||||
</span>
|
||||
<ChevronDown className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[380px] p-0"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<div className="relative">
|
||||
<CommandInput
|
||||
placeholder="Find Project..."
|
||||
value={projectSearch}
|
||||
onValueChange={setProjectSearch}
|
||||
className="w-full focus-visible:ring-0"
|
||||
/>
|
||||
<kbd className="pointer-events-none h-5 absolute right-2 top-1/2 -translate-y-1/2 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 flex">
|
||||
Esc
|
||||
</kbd>
|
||||
</div>
|
||||
<CommandList>
|
||||
<CommandEmpty>No projects found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<ScrollArea className="h-[300px]">
|
||||
{filteredProjects.map((project) => {
|
||||
const totalServices = project.environments.reduce(
|
||||
(total, env) => total + countEnvironmentServices(env),
|
||||
0,
|
||||
);
|
||||
const isSelected = project.projectId === projectId;
|
||||
const isExpanded =
|
||||
expandedProjectId === project.projectId;
|
||||
|
||||
return (
|
||||
<div key={project.projectId}>
|
||||
<CommandItem
|
||||
value={project.projectId}
|
||||
onSelect={() => {
|
||||
if (project.environments.length > 1) {
|
||||
setExpandedProjectId(
|
||||
isExpanded ? null : project.projectId,
|
||||
);
|
||||
} else {
|
||||
handleProjectSelect(project.projectId);
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-between py-3 px-2 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center size-8 rounded-md bg-muted text-xs font-semibold uppercase">
|
||||
{project.name.slice(0, 2)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{project.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{project.environments.length} env
|
||||
{project.environments.length !== 1
|
||||
? "s"
|
||||
: ""}{" "}
|
||||
· {totalServices} service
|
||||
{totalServices !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSelected && (
|
||||
<Check className="size-4 text-primary" />
|
||||
)}
|
||||
{project.environments.length > 1 && (
|
||||
<ChevronRight
|
||||
className={`size-4 text-muted-foreground transition-transform ${isExpanded ? "rotate-90" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
|
||||
{/* Expanded environments */}
|
||||
{isExpanded && (
|
||||
<div className="ml-11 border-l pl-3 py-1 space-y-1">
|
||||
{project.environments.map((env) => {
|
||||
const envServices =
|
||||
countEnvironmentServices(env);
|
||||
const isEnvSelected =
|
||||
env.environmentId === environmentId;
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={env.environmentId}
|
||||
value={env.environmentId}
|
||||
onSelect={() =>
|
||||
handleProjectSelect(
|
||||
project.projectId,
|
||||
env.environmentId,
|
||||
)
|
||||
}
|
||||
className="flex items-center justify-between py-2 px-2 cursor-pointer text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs">{env.name}</p>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{envServices} service
|
||||
{envServices !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
{isEnvSelected && (
|
||||
<Check className="size-3 text-primary" />
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ScrollArea>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Environment Selector */}
|
||||
{projectEnvironments && projectEnvironments.length > 1 && (
|
||||
<Popover open={environmentOpen} onOpenChange={setEnvironmentOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-expanded={environmentOpen}
|
||||
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
||||
>
|
||||
<span className="font-medium max-w-[150px] truncate">
|
||||
{currentEnvironment?.name || "production"}
|
||||
</span>
|
||||
<ChevronDown className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[350px] p-0"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<div className="relative">
|
||||
<CommandInput
|
||||
placeholder="Find Environment..."
|
||||
value={environmentSearch}
|
||||
onValueChange={setEnvironmentSearch}
|
||||
className="w-full focus-visible:ring-0"
|
||||
/>
|
||||
<kbd className="pointer-events-none h-5 absolute right-2 top-1/2 -translate-y-1/2 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 flex">
|
||||
Esc
|
||||
</kbd>
|
||||
</div>
|
||||
<CommandList>
|
||||
<CommandEmpty>No environments found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<ScrollArea className="h-[300px]">
|
||||
{filteredEnvironments.map((env) => {
|
||||
const isSelected =
|
||||
env.environmentId === environmentId;
|
||||
return (
|
||||
<CommandItem
|
||||
key={env.environmentId}
|
||||
value={env.environmentId}
|
||||
onSelect={() =>
|
||||
handleEnvironmentSelect(env.environmentId)
|
||||
}
|
||||
className="flex items-center justify-between py-2 cursor-pointer"
|
||||
>
|
||||
<span className="font-medium">{env.name}</span>
|
||||
{isSelected && (
|
||||
<Check className="size-4 text-primary" />
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</ScrollArea>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{projectEnvironments && projectEnvironments.length === 1 && (
|
||||
<p className="text-sm font-normal ml-1">
|
||||
{currentEnvironment?.name || "production"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Service Selector - only show when viewing a service */}
|
||||
{serviceId && currentService && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-2 h-6" />
|
||||
|
||||
<Popover open={serviceOpen} onOpenChange={setServiceOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-expanded={serviceOpen}
|
||||
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
||||
>
|
||||
{getServiceIcon(currentService.type)}
|
||||
<span className="font-medium max-w-[150px] truncate">
|
||||
{currentService.name}
|
||||
</span>
|
||||
<ChevronDown className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[350px] p-0"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<div className="relative">
|
||||
<CommandInput
|
||||
placeholder="Find Service..."
|
||||
value={serviceSearch}
|
||||
onValueChange={setServiceSearch}
|
||||
className="w-full focus-visible:ring-0"
|
||||
/>
|
||||
<kbd className="pointer-events-none h-5 select-none absolute right-2 top-1/2 -translate-y-1/2 items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 flex">
|
||||
Esc
|
||||
</kbd>
|
||||
</div>
|
||||
<CommandList>
|
||||
<CommandEmpty>No services found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<ScrollArea className="h-[300px]">
|
||||
{filteredServices.map((service) => {
|
||||
const isSelected = service.id === serviceId;
|
||||
return (
|
||||
<CommandItem
|
||||
key={service.id}
|
||||
value={service.id}
|
||||
onSelect={() => handleServiceSelect(service)}
|
||||
className="flex items-center justify-between py-2 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center size-8 rounded-md bg-muted">
|
||||
{getServiceIcon(service.type)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{service.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground capitalize">
|
||||
{service.type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check className="size-4 text-primary" />
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</ScrollArea>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Close button to go back to environment */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 ml-1"
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<X className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -8,6 +8,7 @@ interface Props {
|
||||
export const Logo = ({ className = "size-14", logoUrl }: Props) => {
|
||||
if (logoUrl) {
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: this is for dynamic logo loading
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt="Organization Logo"
|
||||
|
||||
25
apps/dokploy/components/shared/tag-badge.tsx
Normal file
25
apps/dokploy/components/shared/tag-badge.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TagBadgeProps {
|
||||
name: string;
|
||||
color?: string | null;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function TagBadge({ name, color, className, children }: TagBadgeProps) {
|
||||
return (
|
||||
<Badge
|
||||
style={{
|
||||
backgroundColor: color ? `${color}33` : undefined,
|
||||
color: color || undefined,
|
||||
borderColor: color ? `${color}66` : undefined,
|
||||
}}
|
||||
className={cn("border", className)}
|
||||
>
|
||||
{name}
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
127
apps/dokploy/components/shared/tag-filter.tsx
Normal file
127
apps/dokploy/components/shared/tag-filter.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Tags } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { HandleTag } from "@/components/dashboard/settings/tags/handle-tag";
|
||||
import { TagBadge } from "@/components/shared/tag-badge";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface TagFilterProps {
|
||||
tags: Tag[];
|
||||
selectedTags: string[];
|
||||
onTagsChange: (tagIds: string[]) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TagFilter({
|
||||
tags,
|
||||
selectedTags,
|
||||
onTagsChange,
|
||||
className,
|
||||
}: TagFilterProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const handleTagToggle = (tagId: string) => {
|
||||
if (selectedTags.includes(tagId)) {
|
||||
onTagsChange(selectedTags.filter((id) => id !== tagId));
|
||||
} else {
|
||||
onTagsChange([...selectedTags, tagId]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onTagsChange([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn("gap-2", selectedTags.length > 0 && "border-primary")}
|
||||
>
|
||||
<Tags className="h-4 w-4" />
|
||||
<span>Tags</span>
|
||||
{selectedTags.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 px-1 py-0">
|
||||
{selectedTags.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-0" align="start">
|
||||
<Command>
|
||||
<div className="flex items-center border-b px-3">
|
||||
<CommandInput
|
||||
placeholder="Search tags..."
|
||||
className="h-9 focus-visible:ring-0"
|
||||
/>
|
||||
{selectedTags.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
className="h-8 px-2 text-xs"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<div className="flex flex-col items-center gap-2 py-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No tags found.
|
||||
</span>
|
||||
<HandleTag />
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tags.map((tag) => {
|
||||
const isSelected = selectedTags.includes(tag.id);
|
||||
return (
|
||||
<CommandItem
|
||||
key={tag.id}
|
||||
onSelect={() => handleTagToggle(tag.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
className="mr-2"
|
||||
onCheckedChange={() => handleTagToggle(tag.id)}
|
||||
/>
|
||||
<TagBadge name={tag.name} color={tag.color} />
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
apps/dokploy/components/shared/tag-selector.tsx
Normal file
154
apps/dokploy/components/shared/tag-selector.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Check, ChevronsUpDown, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { HandleTag } from "@/components/dashboard/settings/tags/handle-tag";
|
||||
import { TagBadge } from "@/components/shared/tag-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface TagSelectorProps {
|
||||
tags: Tag[];
|
||||
selectedTags: string[];
|
||||
onTagsChange: (tagIds: string[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function TagSelector({
|
||||
tags,
|
||||
selectedTags,
|
||||
onTagsChange,
|
||||
placeholder = "Select tags...",
|
||||
className,
|
||||
disabled = false,
|
||||
}: TagSelectorProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const handleTagToggle = (tagId: string) => {
|
||||
if (selectedTags.includes(tagId)) {
|
||||
onTagsChange(selectedTags.filter((id) => id !== tagId));
|
||||
} else {
|
||||
onTagsChange([...selectedTags, tagId]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTagRemove = (tagId: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
onTagsChange(selectedTags.filter((id) => id !== tagId));
|
||||
};
|
||||
|
||||
const selectedTagObjects = tags.filter((tag) =>
|
||||
selectedTags.includes(tag.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"w-full justify-between min-h-10 h-auto bg-input",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1 flex-1">
|
||||
{selectedTagObjects.length > 0 ? (
|
||||
selectedTagObjects.map((tag) => (
|
||||
<TagBadge
|
||||
key={tag.id}
|
||||
name={tag.name}
|
||||
color={tag.color}
|
||||
className="flex items-center gap-1 pr-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleTagRemove(tag.id, e)}
|
||||
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="h-3 w-3 hover:opacity-70" />
|
||||
<span className="sr-only">Remove {tag.name}</span>
|
||||
</button>
|
||||
</TagBadge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search tags..."
|
||||
className="focus-visible:ring-0"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<div className="flex flex-col items-center gap-2 py-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No tags found.
|
||||
</span>
|
||||
<HandleTag />
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tags.map((tag) => {
|
||||
const isSelected = selectedTags.includes(tag.id);
|
||||
return (
|
||||
<CommandItem
|
||||
key={tag.id}
|
||||
onSelect={() => handleTagToggle(tag.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
className="mr-2"
|
||||
onCheckedChange={() => handleTagToggle(tag.id)}
|
||||
/>
|
||||
<TagBadge
|
||||
name={tag.name}
|
||||
color={tag.color}
|
||||
className="mr-2"
|
||||
/>
|
||||
<Check
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
isSelected ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -44,7 +44,7 @@ const CommandInput = React.forwardRef<
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 focus-visible:ring-inset",
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -82,7 +82,7 @@ const SidebarProvider = React.forwardRef<
|
||||
|
||||
_setOpen(value);
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
// biome-ignore lint/suspicious/noDocumentCookie: This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open],
|
||||
|
||||
19
apps/dokploy/drizzle/0152_odd_firelord.sql
Normal file
19
apps/dokploy/drizzle/0152_odd_firelord.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE "project_tag" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"projectId" text NOT NULL,
|
||||
"tagId" text NOT NULL,
|
||||
CONSTRAINT "unique_project_tag" UNIQUE("projectId","tagId")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tag" (
|
||||
"tagId" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"color" text,
|
||||
"createdAt" text NOT NULL,
|
||||
"organizationId" text NOT NULL,
|
||||
CONSTRAINT "unique_org_tag_name" UNIQUE("organizationId","name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "project_tag" ADD CONSTRAINT "project_tag_projectId_project_projectId_fk" FOREIGN KEY ("projectId") REFERENCES "public"."project"("projectId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "project_tag" ADD CONSTRAINT "project_tag_tagId_tag_tagId_fk" FOREIGN KEY ("tagId") REFERENCES "public"."tag"("tagId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tag" ADD CONSTRAINT "tag_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
|
||||
49
apps/dokploy/drizzle/0153_motionless_mastermind.sql
Normal file
49
apps/dokploy/drizzle/0153_motionless_mastermind.sql
Normal file
@@ -0,0 +1,49 @@
|
||||
CREATE TYPE "public"."sqldNode" AS ENUM('primary', 'replica');--> statement-breakpoint
|
||||
ALTER TYPE "public"."databaseType" ADD VALUE 'libsql';--> statement-breakpoint
|
||||
ALTER TYPE "public"."serviceType" ADD VALUE 'libsql';--> statement-breakpoint
|
||||
CREATE TABLE "libsql" (
|
||||
"libsqlId" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"appName" text NOT NULL,
|
||||
"description" text,
|
||||
"databaseUser" text NOT NULL,
|
||||
"databasePassword" text NOT NULL,
|
||||
"sqldNode" "sqldNode" DEFAULT 'primary' NOT NULL,
|
||||
"sqldPrimaryUrl" text,
|
||||
"enableNamespaces" boolean DEFAULT false NOT NULL,
|
||||
"dockerImage" text NOT NULL,
|
||||
"command" text,
|
||||
"env" text,
|
||||
"memoryReservation" text,
|
||||
"memoryLimit" text,
|
||||
"cpuReservation" text,
|
||||
"cpuLimit" text,
|
||||
"externalPort" integer,
|
||||
"externalGRPCPort" integer,
|
||||
"externalAdminPort" integer,
|
||||
"applicationStatus" "applicationStatus" DEFAULT 'idle' NOT NULL,
|
||||
"healthCheckSwarm" json,
|
||||
"restartPolicySwarm" json,
|
||||
"placementSwarm" json,
|
||||
"updateConfigSwarm" json,
|
||||
"rollbackConfigSwarm" json,
|
||||
"modeSwarm" json,
|
||||
"labelsSwarm" json,
|
||||
"networkSwarm" json,
|
||||
"stopGracePeriodSwarm" bigint,
|
||||
"endpointSpecSwarm" json,
|
||||
"replicas" integer DEFAULT 1 NOT NULL,
|
||||
"createdAt" text NOT NULL,
|
||||
"environmentId" text NOT NULL,
|
||||
"serverId" text,
|
||||
CONSTRAINT "libsql_appName_unique" UNIQUE("appName")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "backup" ADD COLUMN "libsqlId" text;--> statement-breakpoint
|
||||
ALTER TABLE "mount" ADD COLUMN "libsqlId" text;--> statement-breakpoint
|
||||
ALTER TABLE "volume_backup" ADD COLUMN "libsqlId" text;--> statement-breakpoint
|
||||
ALTER TABLE "libsql" ADD CONSTRAINT "libsql_environmentId_environment_environmentId_fk" FOREIGN KEY ("environmentId") REFERENCES "public"."environment"("environmentId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "libsql" ADD CONSTRAINT "libsql_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "backup" ADD CONSTRAINT "backup_libsqlId_libsql_libsqlId_fk" FOREIGN KEY ("libsqlId") REFERENCES "public"."libsql"("libsqlId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "mount" ADD CONSTRAINT "mount_libsqlId_libsql_libsqlId_fk" FOREIGN KEY ("libsqlId") REFERENCES "public"."libsql"("libsqlId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "volume_backup" ADD CONSTRAINT "volume_backup_libsqlId_libsql_libsqlId_fk" FOREIGN KEY ("libsqlId") REFERENCES "public"."libsql"("libsqlId") ON DELETE cascade ON UPDATE no action;
|
||||
10
apps/dokploy/drizzle/0154_careful_eternals.sql
Normal file
10
apps/dokploy/drizzle/0154_careful_eternals.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
ALTER TYPE "public"."notificationType" ADD VALUE 'mattermost' BEFORE 'pushover';--> statement-breakpoint
|
||||
CREATE TABLE "mattermost" (
|
||||
"mattermostId" text PRIMARY KEY NOT NULL,
|
||||
"webhookUrl" text NOT NULL,
|
||||
"channel" text,
|
||||
"username" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "notification" ADD COLUMN "mattermostId" text;--> statement-breakpoint
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_mattermostId_mattermost_mattermostId_fk" FOREIGN KEY ("mattermostId") REFERENCES "public"."mattermost"("mattermostId") ON DELETE cascade ON UPDATE no action;
|
||||
1
apps/dokploy/drizzle/0155_careless_clea.sql
Normal file
1
apps/dokploy/drizzle/0155_careless_clea.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "destination" ADD COLUMN "additionalFlags" text[];
|
||||
7855
apps/dokploy/drizzle/meta/0152_snapshot.json
Normal file
7855
apps/dokploy/drizzle/meta/0152_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
8180
apps/dokploy/drizzle/meta/0153_snapshot.json
Normal file
8180
apps/dokploy/drizzle/meta/0153_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
8237
apps/dokploy/drizzle/meta/0154_snapshot.json
Normal file
8237
apps/dokploy/drizzle/meta/0154_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
8243
apps/dokploy/drizzle/meta/0155_snapshot.json
Normal file
8243
apps/dokploy/drizzle/meta/0155_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1065,6 +1065,34 @@
|
||||
"when": 1773872561300,
|
||||
"tag": "0151_modern_sunfire",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 152,
|
||||
"version": "7",
|
||||
"when": 1773903778014,
|
||||
"tag": "0152_odd_firelord",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 153,
|
||||
"version": "7",
|
||||
"when": 1774322599182,
|
||||
"tag": "0153_motionless_mastermind",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 154,
|
||||
"version": "7",
|
||||
"when": 1774337356154,
|
||||
"tag": "0154_careful_eternals",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 155,
|
||||
"version": "7",
|
||||
"when": 1774794547865,
|
||||
"tag": "0155_careless_clea",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,11 +6,12 @@ import { useCallback, useEffect, useState } from "react";
|
||||
const PAGES = [
|
||||
"compose",
|
||||
"application",
|
||||
"postgres",
|
||||
"redis",
|
||||
"mysql",
|
||||
"libsql",
|
||||
"mariadb",
|
||||
"mongodb",
|
||||
"mysql",
|
||||
"postgres",
|
||||
"redis",
|
||||
] as const;
|
||||
type Page = (typeof PAGES)[number];
|
||||
|
||||
@@ -63,11 +64,12 @@ const REDIS_SHORTCUTS: Shortcuts = {
|
||||
const SHORTCUTS: ShortcutsDictionary = {
|
||||
application: APPLICATION_SHORTCUTS,
|
||||
compose: COMPOSE_SHORTCUTS,
|
||||
postgres: POSTGRES_SHORTCUTS,
|
||||
redis: REDIS_SHORTCUTS,
|
||||
mysql: POSTGRES_SHORTCUTS,
|
||||
libsql: POSTGRES_SHORTCUTS,
|
||||
mariadb: POSTGRES_SHORTCUTS,
|
||||
mongodb: POSTGRES_SHORTCUTS,
|
||||
mysql: POSTGRES_SHORTCUTS,
|
||||
postgres: POSTGRES_SHORTCUTS,
|
||||
redis: REDIS_SHORTCUTS,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/view": "^6.39.15",
|
||||
"@dokploy/server": "workspace:*",
|
||||
"@dokploy/trpc-openapi": "0.0.17",
|
||||
"@dokploy/trpc-openapi": "0.0.18",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@octokit/auth-app": "^6.1.3",
|
||||
@@ -120,7 +120,7 @@
|
||||
"lucide-react": "^0.469.0",
|
||||
"micromatch": "4.0.8",
|
||||
"nanoid": "3.3.11",
|
||||
"next": "^16.1.6",
|
||||
"next": "^16.2.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"nextjs-toploader": "^3.9.17",
|
||||
"node-os-utils": "2.0.1",
|
||||
|
||||
@@ -38,3 +38,5 @@ export const redirectWithError = (res: NextApiResponse, error: string) => {
|
||||
`/dashboard/settings/git-providers?error=${encodeURIComponent(error)}`,
|
||||
);
|
||||
};
|
||||
|
||||
export default findGitea;
|
||||
|
||||
@@ -37,6 +37,7 @@ import { DuplicateProject } from "@/components/dashboard/project/duplicate-proje
|
||||
import { EnvironmentVariables } from "@/components/dashboard/project/environment-variables";
|
||||
import { ProjectEnvironment } from "@/components/dashboard/projects/project-environment";
|
||||
import {
|
||||
LibsqlIcon,
|
||||
MariadbIcon,
|
||||
MongodbIcon,
|
||||
MysqlIcon,
|
||||
@@ -44,8 +45,8 @@ import {
|
||||
RedisIcon,
|
||||
} from "@/components/icons/data-tools-icons";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
|
||||
@@ -111,7 +112,8 @@ export type Services = {
|
||||
| "mysql"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "compose";
|
||||
| "compose"
|
||||
| "libsql";
|
||||
description?: string | null;
|
||||
id: string;
|
||||
createdAt: string;
|
||||
@@ -248,14 +250,27 @@ export const extractServicesFromEnvironment = (
|
||||
};
|
||||
}) || [];
|
||||
|
||||
const libsql: Services[] =
|
||||
environment.libsql?.map((item) => ({
|
||||
name: item.name,
|
||||
type: "libsql",
|
||||
id: item.libsqlId,
|
||||
createdAt: item.createdAt,
|
||||
status: item.applicationStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
serverName: item?.server?.name || null,
|
||||
})) || [];
|
||||
|
||||
allServices.push(
|
||||
...applications,
|
||||
...compose,
|
||||
...libsql,
|
||||
...mysql,
|
||||
...redis,
|
||||
...mongo,
|
||||
...postgres,
|
||||
...mariadb,
|
||||
...compose,
|
||||
);
|
||||
|
||||
allServices.sort((a, b) => {
|
||||
@@ -383,7 +398,8 @@ const EnvironmentPage = (
|
||||
(currentEnvironment.postgres?.length || 0) === 0 &&
|
||||
(currentEnvironment.redis?.length || 0) === 0 &&
|
||||
(currentEnvironment.applications?.length || 0) === 0 &&
|
||||
(currentEnvironment.compose?.length || 0) === 0);
|
||||
(currentEnvironment.compose?.length || 0) === 0 &&
|
||||
(currentEnvironment.libsql?.length || 0) === 0);
|
||||
|
||||
const applications = extractServicesFromEnvironment(currentEnvironment);
|
||||
|
||||
@@ -396,6 +412,7 @@ const EnvironmentPage = (
|
||||
{ value: "mysql", label: "MySQL", icon: MysqlIcon },
|
||||
{ value: "redis", label: "Redis", icon: RedisIcon },
|
||||
{ value: "compose", label: "Compose", icon: CircuitBoard },
|
||||
{ value: "libsql", label: "Libsql", icon: LibsqlIcon },
|
||||
];
|
||||
|
||||
const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
|
||||
@@ -861,18 +878,7 @@ const EnvironmentPage = (
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BreadcrumbSidebar
|
||||
list={[
|
||||
{ name: "Projects", href: "/dashboard/projects" },
|
||||
{
|
||||
name: projectData?.name || "",
|
||||
},
|
||||
{
|
||||
name: currentEnvironment.name,
|
||||
dropdownItems: environmentDropdownItems,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<AdvanceBreadcrumb />
|
||||
<Head>
|
||||
<title>
|
||||
Environment: {currentEnvironment.name} | {projectData?.name} |{" "}
|
||||
@@ -1536,6 +1542,9 @@ const EnvironmentPage = (
|
||||
{service.type === "compose" && (
|
||||
<CircuitBoard className="h-6 w-6" />
|
||||
)}
|
||||
{service.type === "libsql" && (
|
||||
<LibsqlIcon className="h-6 w-6" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</CardTitle>
|
||||
|
||||
@@ -35,7 +35,7 @@ import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
||||
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
@@ -108,22 +108,7 @@ const Service = (
|
||||
return (
|
||||
<div className="pb-10">
|
||||
<UseKeyboardNav forPage="application" />
|
||||
<BreadcrumbSidebar
|
||||
list={[
|
||||
{ name: "Projects", href: "/dashboard/projects" },
|
||||
{
|
||||
name: data?.environment?.project?.name || "",
|
||||
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||
},
|
||||
{
|
||||
name: data?.environment?.name || "",
|
||||
dropdownItems: environmentDropdownItems,
|
||||
},
|
||||
{
|
||||
name: data?.name || "",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<AdvanceBreadcrumb />
|
||||
<Head>
|
||||
<title>
|
||||
Application: {data?.name} - {data?.environment.project.name} |{" "}
|
||||
|
||||
@@ -31,7 +31,7 @@ import { ShowBackups } from "@/components/dashboard/database/backups/show-backup
|
||||
import { ComposeFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-compose-monitoring";
|
||||
import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
@@ -97,22 +97,7 @@ const Service = (
|
||||
return (
|
||||
<div className="pb-10">
|
||||
<UseKeyboardNav forPage="compose" />
|
||||
<BreadcrumbSidebar
|
||||
list={[
|
||||
{ name: "Projects", href: "/dashboard/projects" },
|
||||
{
|
||||
name: data?.environment?.project?.name || "",
|
||||
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||
},
|
||||
{
|
||||
name: data?.environment?.name || "",
|
||||
dropdownItems: environmentDropdownItems,
|
||||
},
|
||||
{
|
||||
name: data?.name || "",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<AdvanceBreadcrumb />
|
||||
<Head>
|
||||
<title>
|
||||
Compose: {data?.name} - {data?.environment?.project?.name} | {appName}
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import { HelpCircle, ServerOff } from "lucide-react";
|
||||
import type {
|
||||
GetServerSidePropsContext,
|
||||
InferGetServerSidePropsType,
|
||||
} from "next";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { type ReactElement, useState } from "react";
|
||||
import superjson from "superjson";
|
||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
|
||||
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||
import { ShowBackups } from "@/components/dashboard/database/backups/show-backups";
|
||||
import { ShowExternalLibsqlCredentials } from "@/components/dashboard/libsql/general/show-external-libsql-credentials";
|
||||
import { ShowGeneralLibsql } from "@/components/dashboard/libsql/general/show-general-libsql";
|
||||
import { ShowInternalLibsqlCredentials } from "@/components/dashboard/libsql/general/show-internal-libsql-credentials";
|
||||
import { UpdateLibsql } from "@/components/dashboard/libsql/update-libsql";
|
||||
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
||||
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
||||
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
|
||||
import { LibsqlIcon } from "@/components/icons/data-tools-icons";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
|
||||
|
||||
const Libsql = (
|
||||
props: InferGetServerSidePropsType<typeof getServerSideProps>,
|
||||
) => {
|
||||
const [_toggleMonitoring, _setToggleMonitoring] = useState(false);
|
||||
|
||||
const { libsqlId, activeTab } = props;
|
||||
const router = useRouter();
|
||||
const { projectId, environmentId } = router.query;
|
||||
const [tab, setSab] = useState<TabState>(activeTab);
|
||||
const { data } = api.libsql.one.useQuery({ libsqlId });
|
||||
const { data: auth } = api.user.get.useQuery();
|
||||
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
|
||||
return (
|
||||
<div className="pb-10">
|
||||
<UseKeyboardNav forPage="libsql" />
|
||||
<AdvanceBreadcrumb />
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Head>
|
||||
<title>
|
||||
Database: {data?.name} - {data?.environment?.project?.name} |
|
||||
Dokploy
|
||||
</title>
|
||||
</Head>
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<div className="relative flex flex-row gap-4">
|
||||
<div className="absolute -right-1 -top-2">
|
||||
<StatusTooltip status={data?.applicationStatus} />
|
||||
</div>
|
||||
|
||||
<LibsqlIcon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
{data?.name}
|
||||
</CardTitle>
|
||||
{data?.description && (
|
||||
<CardDescription>{data?.description}</CardDescription>
|
||||
)}
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{data?.appName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col h-fit w-fit gap-2">
|
||||
<div className="flex flex-row h-fit w-fit gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
!data?.serverId
|
||||
? "default"
|
||||
: data?.server?.serverStatus === "active"
|
||||
? "default"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
</Badge>
|
||||
{data?.server?.serverStatus === "inactive" && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Label className="break-all w-fit flex flex-row gap-1 items-center">
|
||||
<HelpCircle className="size-4 text-muted-foreground" />
|
||||
</Label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="z-[999] w-[300px]"
|
||||
align="start"
|
||||
side="top"
|
||||
>
|
||||
<span>
|
||||
You cannot, deploy this application because the
|
||||
server is inactive, please upgrade your plan to add
|
||||
more servers.
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 justify-end">
|
||||
<UpdateLibsql libsqlId={libsqlId} />
|
||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
||||
<DeleteService id={libsqlId} type="libsql" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
{data?.server?.serverStatus === "inactive" ? (
|
||||
<div className="flex h-[55vh] border-2 rounded-xl border-dashed p-4">
|
||||
<div className="max-w-3xl mx-auto flex flex-col items-center justify-center self-center gap-3">
|
||||
<ServerOff className="size-10 text-muted-foreground self-center" />
|
||||
<span className="text-center text-base text-muted-foreground">
|
||||
This service is hosted on the server {data.server.name},
|
||||
but this server has been disabled because your current
|
||||
plan doesn't include enough servers. Please purchase more
|
||||
servers to regain access to this application.
|
||||
</span>
|
||||
<span className="text-center text-base text-muted-foreground">
|
||||
Go to{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/billing"
|
||||
className="text-primary"
|
||||
>
|
||||
Billing
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Tabs
|
||||
value={tab}
|
||||
defaultValue="general"
|
||||
className="w-full"
|
||||
onValueChange={(e) => {
|
||||
setSab(e as TabState);
|
||||
const newPath = `/dashboard/project/${projectId}/environment/${environmentId}/services/libsql/${libsqlId}?tab=${e}`;
|
||||
|
||||
router.push(newPath, undefined, { shallow: true });
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
|
||||
<TabsList
|
||||
className={cn(
|
||||
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
|
||||
isCloud && data?.serverId
|
||||
? "md:grid-cols-6"
|
||||
: data?.serverId
|
||||
? "md:grid-cols-5"
|
||||
: "md:grid-cols-6",
|
||||
)}
|
||||
>
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="environment">Environment</TabsTrigger>
|
||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||
{((data?.serverId && isCloud) || !data?.server) && (
|
||||
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="backups">Backups</TabsTrigger>
|
||||
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="general">
|
||||
<div className="flex flex-col gap-4 pt-2.5">
|
||||
<ShowGeneralLibsql libsqlId={libsqlId} />
|
||||
<ShowInternalLibsqlCredentials libsqlId={libsqlId} />
|
||||
<ShowExternalLibsqlCredentials libsqlId={libsqlId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="environment">
|
||||
<div className="flex flex-col gap-4 pt-2.5">
|
||||
<ShowEnvironment id={libsqlId} type="libsql" />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="monitoring">
|
||||
<div className="pt-2.5">
|
||||
<div className="flex flex-col gap-4 border rounded-lg p-6">
|
||||
{data?.serverId && isCloud ? (
|
||||
<ContainerPaidMonitoring
|
||||
appName={data?.appName || ""}
|
||||
baseUrl={`${data?.serverId ? `http://${data?.server?.ipAddress}:${data?.server?.metricsConfig?.server?.port}` : "http://localhost:4500"}`}
|
||||
token={
|
||||
data?.server?.metricsConfig?.server?.token || ""
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* {monitoring?.enabledFeatures && (
|
||||
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2">
|
||||
<Label className="text-muted-foreground">
|
||||
Change Monitoring
|
||||
</Label>
|
||||
<Switch
|
||||
checked={toggleMonitoring}
|
||||
onCheckedChange={setToggleMonitoring}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toggleMonitoring ? (
|
||||
<ContainerPaidMonitoring
|
||||
appName={data?.appName || ""}
|
||||
baseUrl={`http://${monitoring?.serverIp}:${monitoring?.metricsConfig?.server?.port}`}
|
||||
token={
|
||||
monitoring?.metricsConfig?.server?.token || ""
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div> */}
|
||||
<ContainerFreeMonitoring
|
||||
appName={data?.appName || ""}
|
||||
/>
|
||||
{/* </div> */}
|
||||
{/* )} */}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="logs">
|
||||
<div className="flex flex-col gap-4 pt-2.5">
|
||||
<ShowDockerLogs
|
||||
serverId={data?.serverId || ""}
|
||||
appName={data?.appName || ""}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="backups">
|
||||
<div className="flex flex-col gap-4 pt-2.5">
|
||||
<ShowBackups
|
||||
id={libsqlId}
|
||||
databaseType="libsql"
|
||||
backupType="database"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="advanced">
|
||||
<div className="flex flex-col gap-4 pt-2.5">
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={libsqlId}
|
||||
type="libsql"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Libsql;
|
||||
Libsql.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout>{page}</DashboardLayout>;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{
|
||||
libsqlId: string;
|
||||
activeTab: TabState;
|
||||
environmentId: string;
|
||||
}>,
|
||||
) {
|
||||
const { query, params, req, res } = ctx;
|
||||
const activeTab = query.tab;
|
||||
|
||||
const { user, session } = await validateRequest(req);
|
||||
if (!user) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
// Fetch data from external API
|
||||
const helpers = createServerSideHelpers({
|
||||
router: appRouter,
|
||||
ctx: {
|
||||
req: req as any,
|
||||
res: res as any,
|
||||
db: null as any,
|
||||
session: session as any,
|
||||
user: user as any,
|
||||
},
|
||||
transformer: superjson,
|
||||
});
|
||||
|
||||
if (typeof params?.libsqlId === "string") {
|
||||
try {
|
||||
await helpers.libsql.one.fetch({
|
||||
libsqlId: params?.libsqlId,
|
||||
});
|
||||
await helpers.settings.isCloud.prefetch();
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
libsqlId: params?.libsqlId,
|
||||
activeTab: (activeTab || "general") as TabState,
|
||||
environmentId: params?.environmentId,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/
|
||||
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
|
||||
import { MariadbIcon } from "@/components/icons/data-tools-icons";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
@@ -78,22 +78,7 @@ const Mariadb = (
|
||||
return (
|
||||
<div className="pb-10">
|
||||
<UseKeyboardNav forPage="mariadb" />
|
||||
<BreadcrumbSidebar
|
||||
list={[
|
||||
{ name: "Projects", href: "/dashboard/projects" },
|
||||
{
|
||||
name: data?.environment?.project?.name || "",
|
||||
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||
},
|
||||
{
|
||||
name: data?.environment?.name || "",
|
||||
dropdownItems: environmentDropdownItems,
|
||||
},
|
||||
{
|
||||
name: data?.name || "",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<AdvanceBreadcrumb />
|
||||
<div className="flex flex-col gap-4">
|
||||
<Head>
|
||||
<title>
|
||||
|
||||
@@ -23,7 +23,7 @@ import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/
|
||||
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
|
||||
import { MongodbIcon } from "@/components/icons/data-tools-icons";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
@@ -77,22 +77,7 @@ const Mongo = (
|
||||
return (
|
||||
<div className="pb-10">
|
||||
<UseKeyboardNav forPage="mongodb" />
|
||||
<BreadcrumbSidebar
|
||||
list={[
|
||||
{ name: "Projects", href: "/dashboard/projects" },
|
||||
{
|
||||
name: data?.environment?.project?.name || "",
|
||||
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||
},
|
||||
{
|
||||
name: data?.environment?.name || "",
|
||||
dropdownItems: environmentDropdownItems,
|
||||
},
|
||||
{
|
||||
name: data?.name || "",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<AdvanceBreadcrumb />
|
||||
<Head>
|
||||
<title>
|
||||
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { UpdateMysql } from "@/components/dashboard/mysql/update-mysql";
|
||||
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
|
||||
import { MysqlIcon } from "@/components/icons/data-tools-icons";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
@@ -76,22 +76,7 @@ const MySql = (
|
||||
return (
|
||||
<div className="pb-10">
|
||||
<UseKeyboardNav forPage="mysql" />
|
||||
<BreadcrumbSidebar
|
||||
list={[
|
||||
{ name: "Projects", href: "/dashboard/projects" },
|
||||
{
|
||||
name: data?.environment?.project?.name || "",
|
||||
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||
},
|
||||
{
|
||||
name: data?.environment?.name || "",
|
||||
dropdownItems: environmentDropdownItems,
|
||||
},
|
||||
{
|
||||
name: data?.name || "",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<AdvanceBreadcrumb />
|
||||
<div className="flex flex-col gap-4">
|
||||
<Head>
|
||||
<title>
|
||||
|
||||
@@ -23,7 +23,7 @@ import { UpdatePostgres } from "@/components/dashboard/postgres/update-postgres"
|
||||
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
|
||||
import { PostgresqlIcon } from "@/components/icons/data-tools-icons";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
@@ -76,22 +76,7 @@ const Postgresql = (
|
||||
return (
|
||||
<div className="pb-10">
|
||||
<UseKeyboardNav forPage="postgres" />
|
||||
<BreadcrumbSidebar
|
||||
list={[
|
||||
{ name: "Projects", href: "/dashboard/projects" },
|
||||
{
|
||||
name: data?.environment?.project?.name || "",
|
||||
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||
},
|
||||
{
|
||||
name: data?.environment?.name || "",
|
||||
dropdownItems: environmentDropdownItems,
|
||||
},
|
||||
{
|
||||
name: data?.name || "",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<AdvanceBreadcrumb />
|
||||
<Head>
|
||||
<title>
|
||||
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
|
||||
|
||||
@@ -22,7 +22,7 @@ import { UpdateRedis } from "@/components/dashboard/redis/update-redis";
|
||||
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
|
||||
import { RedisIcon } from "@/components/icons/data-tools-icons";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
@@ -76,22 +76,7 @@ const Redis = (
|
||||
return (
|
||||
<div className="pb-10">
|
||||
<UseKeyboardNav forPage="redis" />
|
||||
<BreadcrumbSidebar
|
||||
list={[
|
||||
{ name: "Projects", href: "/dashboard/projects" },
|
||||
{
|
||||
name: data?.environment?.project?.name || "",
|
||||
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||
},
|
||||
{
|
||||
name: data?.environment?.name || "",
|
||||
dropdownItems: environmentDropdownItems,
|
||||
},
|
||||
{
|
||||
name: data?.name || "",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<AdvanceBreadcrumb />
|
||||
<Head>
|
||||
<title>
|
||||
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
|
||||
|
||||
66
apps/dokploy/pages/dashboard/settings/tags.tsx
Normal file
66
apps/dokploy/pages/dashboard/settings/tags.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { validateRequest } from "@dokploy/server";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import { TagManager } from "@/components/dashboard/settings/tags/tag-manager";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<TagManager />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
Page.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout>{page}</DashboardLayout>;
|
||||
};
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||
) {
|
||||
const { req, res } = ctx;
|
||||
const { user, session } = await validateRequest(req);
|
||||
|
||||
const helpers = createServerSideHelpers({
|
||||
router: appRouter,
|
||||
ctx: {
|
||||
req: req as any,
|
||||
res: res as any,
|
||||
db: null as any,
|
||||
session: session as any,
|
||||
user: user as any,
|
||||
},
|
||||
transformer: superjson,
|
||||
});
|
||||
|
||||
try {
|
||||
await helpers.user.get.prefetch();
|
||||
await helpers.settings.isCloud.prefetch();
|
||||
|
||||
const userPermissions = await helpers.user.getPermissions.fetch();
|
||||
|
||||
if (!userPermissions?.tag.read) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { gitProviderRouter } from "./routers/git-provider";
|
||||
import { giteaRouter } from "./routers/gitea";
|
||||
import { githubRouter } from "./routers/github";
|
||||
import { gitlabRouter } from "./routers/gitlab";
|
||||
import { libsqlRouter } from "./routers/libsql";
|
||||
import { mariadbRouter } from "./routers/mariadb";
|
||||
import { mongoRouter } from "./routers/mongo";
|
||||
import { mountRouter } from "./routers/mount";
|
||||
@@ -23,15 +24,15 @@ import { mysqlRouter } from "./routers/mysql";
|
||||
import { notificationRouter } from "./routers/notification";
|
||||
import { organizationRouter } from "./routers/organization";
|
||||
import { patchRouter } from "./routers/patch";
|
||||
import { portRouter } from "./routers/port";
|
||||
import { postgresRouter } from "./routers/postgres";
|
||||
import { previewDeploymentRouter } from "./routers/preview-deployment";
|
||||
import { projectRouter } from "./routers/project";
|
||||
import { auditLogRouter } from "./routers/proprietary/audit-log";
|
||||
import { customRoleRouter } from "./routers/proprietary/custom-role";
|
||||
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||
import { ssoRouter } from "./routers/proprietary/sso";
|
||||
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
|
||||
import { portRouter } from "./routers/port";
|
||||
import { postgresRouter } from "./routers/postgres";
|
||||
import { previewDeploymentRouter } from "./routers/preview-deployment";
|
||||
import { projectRouter } from "./routers/project";
|
||||
import { redirectsRouter } from "./routers/redirects";
|
||||
import { redisRouter } from "./routers/redis";
|
||||
import { registryRouter } from "./routers/registry";
|
||||
@@ -43,6 +44,7 @@ import { settingsRouter } from "./routers/settings";
|
||||
import { sshRouter } from "./routers/ssh-key";
|
||||
import { stripeRouter } from "./routers/stripe";
|
||||
import { swarmRouter } from "./routers/swarm";
|
||||
import { tagRouter } from "./routers/tag";
|
||||
import { userRouter } from "./routers/user";
|
||||
import { volumeBackupsRouter } from "./routers/volume-backups";
|
||||
/**
|
||||
@@ -53,39 +55,40 @@ import { volumeBackupsRouter } from "./routers/volume-backups";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
admin: adminRouter,
|
||||
docker: dockerRouter,
|
||||
project: projectRouter,
|
||||
application: applicationRouter,
|
||||
mysql: mysqlRouter,
|
||||
postgres: postgresRouter,
|
||||
redis: redisRouter,
|
||||
mongo: mongoRouter,
|
||||
mariadb: mariadbRouter,
|
||||
compose: composeRouter,
|
||||
user: userRouter,
|
||||
domain: domainRouter,
|
||||
destination: destinationRouter,
|
||||
backup: backupRouter,
|
||||
deployment: deploymentRouter,
|
||||
previewDeployment: previewDeploymentRouter,
|
||||
mounts: mountRouter,
|
||||
certificates: certificateRouter,
|
||||
settings: settingsRouter,
|
||||
security: securityRouter,
|
||||
redirects: redirectsRouter,
|
||||
port: portRouter,
|
||||
registry: registryRouter,
|
||||
cluster: clusterRouter,
|
||||
notification: notificationRouter,
|
||||
sshKey: sshRouter,
|
||||
gitProvider: gitProviderRouter,
|
||||
gitea: giteaRouter,
|
||||
bitbucket: bitbucketRouter,
|
||||
gitlab: gitlabRouter,
|
||||
certificates: certificateRouter,
|
||||
cluster: clusterRouter,
|
||||
compose: composeRouter,
|
||||
deployment: deploymentRouter,
|
||||
destination: destinationRouter,
|
||||
docker: dockerRouter,
|
||||
domain: domainRouter,
|
||||
gitea: giteaRouter,
|
||||
gitProvider: gitProviderRouter,
|
||||
github: githubRouter,
|
||||
gitlab: gitlabRouter,
|
||||
libsql: libsqlRouter,
|
||||
mariadb: mariadbRouter,
|
||||
mongo: mongoRouter,
|
||||
mounts: mountRouter,
|
||||
mysql: mysqlRouter,
|
||||
notification: notificationRouter,
|
||||
port: portRouter,
|
||||
postgres: postgresRouter,
|
||||
previewDeployment: previewDeploymentRouter,
|
||||
project: projectRouter,
|
||||
redirects: redirectsRouter,
|
||||
redis: redisRouter,
|
||||
registry: registryRouter,
|
||||
security: securityRouter,
|
||||
server: serverRouter,
|
||||
settings: settingsRouter,
|
||||
sshKey: sshRouter,
|
||||
stripe: stripeRouter,
|
||||
swarm: swarmRouter,
|
||||
user: userRouter,
|
||||
ai: aiRouter,
|
||||
organization: organizationRouter,
|
||||
licenseKey: licenseKeyRouter,
|
||||
@@ -97,6 +100,7 @@ export const appRouter = createTRPCRouter({
|
||||
rollback: rollbackRouter,
|
||||
volumeBackups: volumeBackupsRouter,
|
||||
environment: environmentRouter,
|
||||
tag: tagRouter,
|
||||
patch: patchRouter,
|
||||
});
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import { TRPCError } from "@trpc/server";
|
||||
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { zfd } from "zod-form-data";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
@@ -769,21 +770,17 @@ export const applicationRouter = createTRPCRouter({
|
||||
}),
|
||||
|
||||
dropDeployment: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
path: "/drop-deployment",
|
||||
method: "POST",
|
||||
override: true,
|
||||
enabled: false,
|
||||
},
|
||||
})
|
||||
.input(z.instanceof(FormData))
|
||||
.input(
|
||||
zfd.formData({
|
||||
applicationId: z.string(),
|
||||
zip: zfd.file(),
|
||||
dropBuildPath: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const formData = input;
|
||||
|
||||
const zipFile = formData.get("zip") as File;
|
||||
const applicationId = formData.get("applicationId") as string;
|
||||
const dropBuildPath = formData.get("dropBuildPath") as string | null;
|
||||
const zipFile = input.zip;
|
||||
const applicationId = input.applicationId;
|
||||
const dropBuildPath = input.dropBuildPath ?? null;
|
||||
|
||||
await checkServicePermissionAndAccess(ctx, applicationId, {
|
||||
deployment: ["create"],
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
findBackupById,
|
||||
findComposeByBackupId,
|
||||
findComposeById,
|
||||
findLibsqlByBackupId,
|
||||
findLibsqlById,
|
||||
findMariadbByBackupId,
|
||||
findMariadbById,
|
||||
findMongoByBackupId,
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
keepLatestNBackups,
|
||||
removeBackupById,
|
||||
removeScheduleBackup,
|
||||
runLibsqlBackup,
|
||||
runMariadbBackup,
|
||||
runMongoBackup,
|
||||
runMySqlBackup,
|
||||
@@ -36,6 +39,7 @@ import {
|
||||
} from "@dokploy/server/utils/process/execAsync";
|
||||
import {
|
||||
restoreComposeBackup,
|
||||
restoreLibsqlBackup,
|
||||
restoreMariadbBackup,
|
||||
restoreMongoBackup,
|
||||
restoreMySqlBackup,
|
||||
@@ -82,6 +86,7 @@ export const backupRouter = createTRPCRouter({
|
||||
input.mysqlId ||
|
||||
input.mariadbId ||
|
||||
input.mongoId ||
|
||||
input.libsqlId ||
|
||||
input.composeId;
|
||||
if (serviceId) {
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
@@ -103,6 +108,8 @@ export const backupRouter = createTRPCRouter({
|
||||
serverId = backup.mongo.serverId;
|
||||
} else if (databaseType === "mariadb" && backup.mariadb?.serverId) {
|
||||
serverId = backup.mariadb.serverId;
|
||||
} else if (databaseType === "libsql" && backup.libsql?.serverId) {
|
||||
serverId = backup.libsql.serverId;
|
||||
} else if (
|
||||
backup.backupType === "compose" &&
|
||||
backup.compose?.serverId
|
||||
@@ -154,6 +161,7 @@ export const backupRouter = createTRPCRouter({
|
||||
backup.mysqlId ||
|
||||
backup.mariadbId ||
|
||||
backup.mongoId ||
|
||||
backup.libsqlId ||
|
||||
backup.composeId;
|
||||
if (serviceId) {
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
@@ -173,6 +181,7 @@ export const backupRouter = createTRPCRouter({
|
||||
existing.mysqlId ||
|
||||
existing.mariadbId ||
|
||||
existing.mongoId ||
|
||||
existing.libsqlId ||
|
||||
existing.composeId;
|
||||
if (serviceId) {
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
@@ -229,6 +238,7 @@ export const backupRouter = createTRPCRouter({
|
||||
backup.mysqlId ||
|
||||
backup.mariadbId ||
|
||||
backup.mongoId ||
|
||||
backup.libsqlId ||
|
||||
backup.composeId;
|
||||
if (serviceId) {
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
@@ -400,6 +410,33 @@ export const backupRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
}),
|
||||
manualBackupLibsql: protectedProcedure
|
||||
.input(apiFindOneBackup)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const backup = await findBackupById(input.backupId);
|
||||
if (backup.libsqlId) {
|
||||
await checkServicePermissionAndAccess(ctx, backup.libsqlId, {
|
||||
backup: ["create"],
|
||||
});
|
||||
}
|
||||
const libsql = await findLibsqlByBackupId(backup.backupId);
|
||||
await runLibsqlBackup(libsql, backup);
|
||||
await keepLatestNBackups(backup, libsql?.serverId);
|
||||
await audit(ctx, {
|
||||
action: "run",
|
||||
resourceType: "backup",
|
||||
resourceId: backup.backupId,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error running manual Libsql backup ",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
manualBackupWebServer: withPermission("backup", "create")
|
||||
.input(apiFindOneBackup)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
@@ -536,6 +573,12 @@ export const backupRouter = createTRPCRouter({
|
||||
queue.push(log);
|
||||
});
|
||||
}
|
||||
if (input.databaseType === "libsql") {
|
||||
const libsql = await findLibsqlById(input.databaseId);
|
||||
restoreLibsqlBackup(libsql, destination, input, (log) => {
|
||||
queue.push(log);
|
||||
});
|
||||
}
|
||||
if (input.databaseType === "web-server") {
|
||||
restoreWebServerBackup(destination, input.backupFile, (log) => {
|
||||
queue.push(log);
|
||||
|
||||
@@ -47,8 +47,15 @@ export const destinationRouter = createTRPCRouter({
|
||||
testConnection: withPermission("destination", "create")
|
||||
.input(apiCreateDestination)
|
||||
.mutation(async ({ input }) => {
|
||||
const { secretAccessKey, bucket, region, endpoint, accessKey, provider } =
|
||||
input;
|
||||
const {
|
||||
secretAccessKey,
|
||||
bucket,
|
||||
region,
|
||||
endpoint,
|
||||
accessKey,
|
||||
provider,
|
||||
additionalFlags,
|
||||
} = input;
|
||||
try {
|
||||
const rcloneFlags = [
|
||||
`--s3-access-key-id="${accessKey}"`,
|
||||
@@ -65,6 +72,9 @@ export const destinationRouter = createTRPCRouter({
|
||||
if (provider) {
|
||||
rcloneFlags.unshift(`--s3-provider="${provider}"`);
|
||||
}
|
||||
if (additionalFlags?.length) {
|
||||
rcloneFlags.push(...additionalFlags);
|
||||
}
|
||||
const rcloneDestination = `:s3:${bucket}`;
|
||||
const rcloneCommand = `rclone ls ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
|
||||
@@ -159,7 +169,14 @@ export const destinationRouter = createTRPCRouter({
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error?.message
|
||||
: "Error connecting to bucket",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
containerRemove,
|
||||
containerRestart,
|
||||
findServerById,
|
||||
getConfig,
|
||||
@@ -52,6 +53,32 @@ export const dockerRouter = createTRPCRouter({
|
||||
return result;
|
||||
}),
|
||||
|
||||
removeContainer: withPermission("docker", "read")
|
||||
.input(
|
||||
z.object({
|
||||
containerId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(containerIdRegex, "Invalid container id."),
|
||||
serverId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
await containerRemove(input.containerId, input.serverId);
|
||||
await audit(ctx, {
|
||||
action: "delete",
|
||||
resourceType: "docker",
|
||||
resourceId: input.containerId,
|
||||
resourceName: input.containerId,
|
||||
});
|
||||
}),
|
||||
|
||||
getConfig: withPermission("docker", "read")
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
@@ -38,6 +38,12 @@ const filterEnvironmentServices = (
|
||||
applications: environment.applications.filter((app: any) =>
|
||||
accessedServices.includes(app.applicationId),
|
||||
),
|
||||
compose: environment.compose.filter((comp: any) =>
|
||||
accessedServices.includes(comp.composeId),
|
||||
),
|
||||
libsql: environment.libsql.filter((db: any) =>
|
||||
accessedServices.includes(db.libsqlId),
|
||||
),
|
||||
mariadb: environment.mariadb.filter((db: any) =>
|
||||
accessedServices.includes(db.mariadbId),
|
||||
),
|
||||
@@ -53,9 +59,6 @@ const filterEnvironmentServices = (
|
||||
redis: environment.redis.filter((db: any) =>
|
||||
accessedServices.includes(db.redisId),
|
||||
),
|
||||
compose: environment.compose.filter((comp: any) =>
|
||||
accessedServices.includes(comp.composeId),
|
||||
),
|
||||
});
|
||||
|
||||
export const environmentRouter = createTRPCRouter({
|
||||
|
||||
453
apps/dokploy/server/api/routers/libsql.ts
Normal file
453
apps/dokploy/server/api/routers/libsql.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
import {
|
||||
checkPortInUse,
|
||||
createLibsql,
|
||||
createMount,
|
||||
deployLibsql,
|
||||
findEnvironmentById,
|
||||
findLibsqlById,
|
||||
findProjectById,
|
||||
IS_CLOUD,
|
||||
rebuildDatabase,
|
||||
removeLibsqlById,
|
||||
removeService,
|
||||
startService,
|
||||
startServiceRemote,
|
||||
stopService,
|
||||
stopServiceRemote,
|
||||
updateLibsqlById,
|
||||
} from "@dokploy/server";
|
||||
import {
|
||||
addNewService,
|
||||
checkServiceAccess,
|
||||
checkServicePermissionAndAccess,
|
||||
} from "@dokploy/server/services/permission";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import { db } from "@/server/db";
|
||||
import {
|
||||
apiChangeLibsqlStatus,
|
||||
apiCreateLibsql,
|
||||
apiDeployLibsql,
|
||||
apiFindOneLibsql,
|
||||
apiRebuildLibsql,
|
||||
apiResetLibsql,
|
||||
apiSaveEnvironmentVariablesLibsql,
|
||||
apiSaveExternalPortsLibsql,
|
||||
apiUpdateLibsql,
|
||||
libsql as libsqlTable,
|
||||
} from "@/server/db/schema";
|
||||
export const libsqlRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.input(apiCreateLibsql)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const environment = await findEnvironmentById(input.environmentId);
|
||||
const project = await findProjectById(environment.projectId);
|
||||
|
||||
await checkServiceAccess(ctx, project.projectId, "create");
|
||||
|
||||
if (IS_CLOUD && !input.serverId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You need to use a server to create a Libsql",
|
||||
});
|
||||
}
|
||||
|
||||
if (project.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this project",
|
||||
});
|
||||
}
|
||||
const newLibsql = await createLibsql({
|
||||
...input,
|
||||
});
|
||||
await addNewService(ctx, newLibsql.libsqlId);
|
||||
|
||||
await createMount({
|
||||
serviceId: newLibsql.libsqlId,
|
||||
serviceType: "libsql",
|
||||
volumeName: `${newLibsql.appName}-data`,
|
||||
mountPath: "/var/lib/sqld",
|
||||
type: "volume",
|
||||
});
|
||||
|
||||
await audit(ctx, {
|
||||
action: "create",
|
||||
resourceType: "service",
|
||||
resourceId: newLibsql.libsqlId,
|
||||
resourceName: newLibsql.appName,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
one: protectedProcedure
|
||||
.input(apiFindOneLibsql)
|
||||
.query(async ({ input, ctx }) => {
|
||||
await checkServiceAccess(ctx, input.libsqlId, "read");
|
||||
|
||||
const libsql = await findLibsqlById(input.libsqlId);
|
||||
if (
|
||||
libsql.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this Libsql",
|
||||
});
|
||||
}
|
||||
return libsql;
|
||||
}),
|
||||
|
||||
start: protectedProcedure
|
||||
.input(apiFindOneLibsql)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
|
||||
deployment: ["create"],
|
||||
});
|
||||
const libsql = await findLibsqlById(input.libsqlId);
|
||||
|
||||
if (libsql.serverId) {
|
||||
await startServiceRemote(libsql.serverId, libsql.appName);
|
||||
} else {
|
||||
await startService(libsql.appName);
|
||||
}
|
||||
await updateLibsqlById(input.libsqlId, {
|
||||
applicationStatus: "done",
|
||||
});
|
||||
|
||||
await audit(ctx, {
|
||||
action: "start",
|
||||
resourceType: "service",
|
||||
resourceId: libsql.libsqlId,
|
||||
resourceName: libsql.appName,
|
||||
});
|
||||
return libsql;
|
||||
}),
|
||||
stop: protectedProcedure
|
||||
.input(apiFindOneLibsql)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
|
||||
deployment: ["create"],
|
||||
});
|
||||
const libsql = await findLibsqlById(input.libsqlId);
|
||||
|
||||
if (libsql.serverId) {
|
||||
await stopServiceRemote(libsql.serverId, libsql.appName);
|
||||
} else {
|
||||
await stopService(libsql.appName);
|
||||
}
|
||||
await updateLibsqlById(input.libsqlId, {
|
||||
applicationStatus: "idle",
|
||||
});
|
||||
|
||||
await audit(ctx, {
|
||||
action: "stop",
|
||||
resourceType: "service",
|
||||
resourceId: libsql.libsqlId,
|
||||
resourceName: libsql.appName,
|
||||
});
|
||||
return libsql;
|
||||
}),
|
||||
saveExternalPorts: protectedProcedure
|
||||
.input(apiSaveExternalPortsLibsql)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
|
||||
service: ["create"],
|
||||
});
|
||||
const libsql = await findLibsqlById(input.libsqlId);
|
||||
|
||||
if (libsql.sqldNode === "replica" && input.externalGRPCPort !== null) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "externalGRPCPort cannot be set when sqldNode is 'replica'",
|
||||
});
|
||||
}
|
||||
|
||||
const portsToCheck = [
|
||||
{
|
||||
port: input.externalPort,
|
||||
name: "externalPort",
|
||||
current: libsql.externalPort,
|
||||
},
|
||||
{
|
||||
port: input.externalGRPCPort,
|
||||
name: "externalGRPCPort",
|
||||
current: libsql.externalGRPCPort,
|
||||
},
|
||||
{
|
||||
port: input.externalAdminPort,
|
||||
name: "externalAdminPort",
|
||||
current: libsql.externalAdminPort,
|
||||
},
|
||||
];
|
||||
|
||||
for (const { port, name, current } of portsToCheck) {
|
||||
if (port && port !== current) {
|
||||
const portCheck = await checkPortInUse(
|
||||
port,
|
||||
libsql.serverId || undefined,
|
||||
);
|
||||
if (portCheck.isInUse) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `Port ${port} (${name}) is already in use by ${portCheck.conflictingContainer}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await updateLibsqlById(input.libsqlId, {
|
||||
externalPort: input.externalPort,
|
||||
externalGRPCPort: input.externalGRPCPort,
|
||||
externalAdminPort: input.externalAdminPort,
|
||||
});
|
||||
await deployLibsql(input.libsqlId);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "service",
|
||||
resourceId: libsql.libsqlId,
|
||||
resourceName: libsql.appName,
|
||||
});
|
||||
return libsql;
|
||||
}),
|
||||
deploy: protectedProcedure
|
||||
.input(apiDeployLibsql)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
|
||||
deployment: ["create"],
|
||||
});
|
||||
const libsql = await findLibsqlById(input.libsqlId);
|
||||
await audit(ctx, {
|
||||
action: "deploy",
|
||||
resourceType: "service",
|
||||
resourceId: libsql.libsqlId,
|
||||
resourceName: libsql.appName,
|
||||
});
|
||||
return deployLibsql(input.libsqlId);
|
||||
}),
|
||||
deployWithLogs: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
path: "/deploy/libsql-with-logs",
|
||||
method: "POST",
|
||||
override: true,
|
||||
enabled: false,
|
||||
},
|
||||
})
|
||||
.input(apiDeployLibsql)
|
||||
.subscription(async function* ({ input, ctx, signal }) {
|
||||
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
|
||||
deployment: ["create"],
|
||||
});
|
||||
const queue: string[] = [];
|
||||
const done = false;
|
||||
|
||||
deployLibsql(input.libsqlId, (log) => {
|
||||
queue.push(log);
|
||||
});
|
||||
|
||||
while (!done || queue.length > 0) {
|
||||
if (queue.length > 0) {
|
||||
yield queue.shift()!;
|
||||
} else {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}),
|
||||
changeStatus: protectedProcedure
|
||||
.input(apiChangeLibsqlStatus)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
|
||||
deployment: ["create"],
|
||||
});
|
||||
const libsql = await findLibsqlById(input.libsqlId);
|
||||
await updateLibsqlById(input.libsqlId, {
|
||||
applicationStatus: input.applicationStatus,
|
||||
});
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "service",
|
||||
resourceId: libsql.libsqlId,
|
||||
resourceName: libsql.appName,
|
||||
});
|
||||
return libsql;
|
||||
}),
|
||||
remove: protectedProcedure
|
||||
.input(apiFindOneLibsql)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await checkServiceAccess(ctx, input.libsqlId, "delete");
|
||||
|
||||
const libsql = await findLibsqlById(input.libsqlId);
|
||||
|
||||
if (
|
||||
libsql.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to delete this Libsql",
|
||||
});
|
||||
}
|
||||
await audit(ctx, {
|
||||
action: "delete",
|
||||
resourceType: "service",
|
||||
resourceId: libsql.libsqlId,
|
||||
resourceName: libsql.appName,
|
||||
});
|
||||
const cleanupOperations = [
|
||||
async () => await removeService(libsql?.appName, libsql.serverId),
|
||||
async () => await removeLibsqlById(input.libsqlId),
|
||||
];
|
||||
|
||||
for (const operation of cleanupOperations) {
|
||||
try {
|
||||
await operation();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return libsql;
|
||||
}),
|
||||
saveEnvironment: protectedProcedure
|
||||
.input(apiSaveEnvironmentVariablesLibsql)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
|
||||
envVars: ["write"],
|
||||
});
|
||||
const service = await updateLibsqlById(input.libsqlId, {
|
||||
env: input.env,
|
||||
});
|
||||
|
||||
if (!service) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error adding environment variables",
|
||||
});
|
||||
}
|
||||
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "service",
|
||||
resourceId: input.libsqlId,
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
reload: protectedProcedure
|
||||
.input(apiResetLibsql)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
|
||||
deployment: ["create"],
|
||||
});
|
||||
const libsql = await findLibsqlById(input.libsqlId);
|
||||
if (libsql.serverId) {
|
||||
await stopServiceRemote(libsql.serverId, libsql.appName);
|
||||
} else {
|
||||
await stopService(libsql.appName);
|
||||
}
|
||||
await updateLibsqlById(input.libsqlId, {
|
||||
applicationStatus: "idle",
|
||||
});
|
||||
|
||||
if (libsql.serverId) {
|
||||
await startServiceRemote(libsql.serverId, libsql.appName);
|
||||
} else {
|
||||
await startService(libsql.appName);
|
||||
}
|
||||
await updateLibsqlById(input.libsqlId, {
|
||||
applicationStatus: "done",
|
||||
});
|
||||
await audit(ctx, {
|
||||
action: "reload",
|
||||
resourceType: "service",
|
||||
resourceId: libsql.libsqlId,
|
||||
resourceName: libsql.appName,
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
update: protectedProcedure
|
||||
.input(apiUpdateLibsql)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { libsqlId, ...rest } = input;
|
||||
await checkServicePermissionAndAccess(ctx, libsqlId, {
|
||||
service: ["create"],
|
||||
});
|
||||
const libsql = await updateLibsqlById(libsqlId, {
|
||||
...rest,
|
||||
});
|
||||
|
||||
if (!libsql) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error updating Libsql",
|
||||
});
|
||||
}
|
||||
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "service",
|
||||
resourceId: libsqlId,
|
||||
resourceName: libsql.appName,
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
move: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
libsqlId: z.string(),
|
||||
targetEnvironmentId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
|
||||
service: ["create"],
|
||||
});
|
||||
|
||||
const updatedLibsql = await db
|
||||
.update(libsqlTable)
|
||||
.set({
|
||||
environmentId: input.targetEnvironmentId,
|
||||
})
|
||||
.where(eq(libsqlTable.libsqlId, input.libsqlId))
|
||||
.returning()
|
||||
.then((res) => res[0]);
|
||||
|
||||
if (!updatedLibsql) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to move libsql",
|
||||
});
|
||||
}
|
||||
|
||||
await audit(ctx, {
|
||||
action: "move",
|
||||
resourceType: "service",
|
||||
resourceId: updatedLibsql.libsqlId,
|
||||
resourceName: updatedLibsql.appName,
|
||||
});
|
||||
return updatedLibsql;
|
||||
}),
|
||||
rebuild: protectedProcedure
|
||||
.input(apiRebuildLibsql)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await checkServicePermissionAndAccess(ctx, input.libsqlId, {
|
||||
deployment: ["create"],
|
||||
});
|
||||
|
||||
await rebuildDatabase(input.libsqlId, "libsql");
|
||||
await audit(ctx, {
|
||||
action: "rebuild",
|
||||
resourceType: "service",
|
||||
resourceId: input.libsqlId,
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
deleteMount,
|
||||
findApplicationById,
|
||||
findComposeById,
|
||||
findLibsqlById,
|
||||
findMariadbById,
|
||||
findMongoById,
|
||||
findMountById,
|
||||
@@ -13,13 +14,14 @@ import {
|
||||
getServiceContainer,
|
||||
updateMount,
|
||||
} from "@dokploy/server";
|
||||
import type { ServiceType } from "@dokploy/server/db/schema/mount";
|
||||
import {
|
||||
checkServiceAccess,
|
||||
checkServicePermissionAndAccess,
|
||||
} from "@dokploy/server/services/permission";
|
||||
import type { ServiceType } from "@dokploy/server/db/schema/mount";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import {
|
||||
apiCreateMount,
|
||||
apiFindMountByApplicationId,
|
||||
@@ -28,7 +30,6 @@ import {
|
||||
apiUpdateMount,
|
||||
} from "@/server/db/schema";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
|
||||
async function getServiceOrganizationId(
|
||||
serviceId: string,
|
||||
@@ -63,6 +64,10 @@ async function getServiceOrganizationId(
|
||||
const compose = await findComposeById(serviceId);
|
||||
return compose?.environment?.project?.organizationId ?? null;
|
||||
}
|
||||
case "libsql": {
|
||||
const libsql = await findLibsqlById(serviceId);
|
||||
return libsql?.environment?.project?.organizationId ?? null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -95,6 +100,7 @@ export const mountRouter = createTRPCRouter({
|
||||
mount.mongoId ||
|
||||
mount.mysqlId ||
|
||||
mount.redisId ||
|
||||
mount.libsqlId ||
|
||||
mount.composeId;
|
||||
if (serviceId) {
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
@@ -120,6 +126,7 @@ export const mountRouter = createTRPCRouter({
|
||||
mount.mongoId ||
|
||||
mount.mysqlId ||
|
||||
mount.redisId ||
|
||||
mount.libsqlId ||
|
||||
mount.composeId;
|
||||
if (serviceId) {
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
@@ -139,6 +146,7 @@ export const mountRouter = createTRPCRouter({
|
||||
mount.mongoId ||
|
||||
mount.mysqlId ||
|
||||
mount.redisId ||
|
||||
mount.libsqlId ||
|
||||
mount.composeId;
|
||||
if (serviceId) {
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
@@ -169,7 +177,6 @@ export const mountRouter = createTRPCRouter({
|
||||
listByServiceId: protectedProcedure
|
||||
.input(apiFindMountByApplicationId)
|
||||
.query(async ({ input, ctx }) => {
|
||||
console.log("input", input);
|
||||
await checkServiceAccess(ctx, input.serviceId, "read");
|
||||
const organizationId = await getServiceOrganizationId(
|
||||
input.serviceId,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createEmailNotification,
|
||||
createGotifyNotification,
|
||||
createLarkNotification,
|
||||
createMattermostNotification,
|
||||
createNtfyNotification,
|
||||
createPushoverNotification,
|
||||
createResendNotification,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
sendEmailNotification,
|
||||
sendGotifyNotification,
|
||||
sendLarkNotification,
|
||||
sendMattermostNotification,
|
||||
sendNtfyNotification,
|
||||
sendPushoverNotification,
|
||||
sendResendNotification,
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
updateEmailNotification,
|
||||
updateGotifyNotification,
|
||||
updateLarkNotification,
|
||||
updateMattermostNotification,
|
||||
updateNtfyNotification,
|
||||
updatePushoverNotification,
|
||||
updateResendNotification,
|
||||
@@ -54,6 +57,7 @@ import {
|
||||
apiCreateEmail,
|
||||
apiCreateGotify,
|
||||
apiCreateLark,
|
||||
apiCreateMattermost,
|
||||
apiCreateNtfy,
|
||||
apiCreatePushover,
|
||||
apiCreateResend,
|
||||
@@ -66,6 +70,7 @@ import {
|
||||
apiTestEmailConnection,
|
||||
apiTestGotifyConnection,
|
||||
apiTestLarkConnection,
|
||||
apiTestMattermostConnection,
|
||||
apiTestNtfyConnection,
|
||||
apiTestPushoverConnection,
|
||||
apiTestResendConnection,
|
||||
@@ -77,6 +82,7 @@ import {
|
||||
apiUpdateEmail,
|
||||
apiUpdateGotify,
|
||||
apiUpdateLark,
|
||||
apiUpdateMattermost,
|
||||
apiUpdateNtfy,
|
||||
apiUpdatePushover,
|
||||
apiUpdateResend,
|
||||
@@ -473,6 +479,7 @@ export const notificationRouter = createTRPCRouter({
|
||||
resend: true,
|
||||
gotify: true,
|
||||
ntfy: true,
|
||||
mattermost: true,
|
||||
custom: true,
|
||||
lark: true,
|
||||
pushover: true,
|
||||
@@ -675,6 +682,74 @@ export const notificationRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
}),
|
||||
createMattermost: withPermission("notification", "create")
|
||||
.input(apiCreateMattermost)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
await createMattermostNotification(
|
||||
input,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "create",
|
||||
resourceType: "notification",
|
||||
resourceName: input.name,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error creating the notification",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
updateMattermost: withPermission("notification", "update")
|
||||
.input(apiUpdateMattermost)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const notification = await findNotificationById(input.notificationId);
|
||||
if (
|
||||
IS_CLOUD &&
|
||||
notification.organizationId !== ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to update this notification",
|
||||
});
|
||||
}
|
||||
const result = await updateMattermostNotification({
|
||||
...input,
|
||||
organizationId: ctx.session.activeOrganizationId,
|
||||
});
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "notification",
|
||||
resourceId: input.notificationId,
|
||||
resourceName: notification.name,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
testMattermostConnection: withPermission("notification", "create")
|
||||
.input(apiTestMattermostConnection)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
await sendMattermostNotification(input, {
|
||||
text: "Hi, From Dokploy 👋",
|
||||
channel: input.channel,
|
||||
username: input.username || "Dokploy Bot",
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error testing the notification",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
createCustom: withPermission("notification", "create")
|
||||
.input(apiCreateCustom)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createBackup,
|
||||
createCompose,
|
||||
createDomain,
|
||||
createLibsql,
|
||||
createMariadb,
|
||||
createMongo,
|
||||
createMount,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
findApplicationById,
|
||||
findComposeById,
|
||||
findEnvironmentById,
|
||||
findLibsqlById,
|
||||
findMariadbById,
|
||||
findMongoById,
|
||||
findMySqlById,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
IS_CLOUD,
|
||||
updateProjectById,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
addNewEnvironment,
|
||||
addNewProject,
|
||||
@@ -35,17 +38,16 @@ import {
|
||||
checkProjectAccess,
|
||||
findMemberByUserId,
|
||||
} from "@dokploy/server/services/permission";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
|
||||
import type { AnyPgColumn } from "drizzle-orm/pg-core";
|
||||
import { z } from "zod";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
withPermission,
|
||||
} from "@/server/api/trpc";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import {
|
||||
apiCreateProject,
|
||||
apiFindOneProject,
|
||||
@@ -54,6 +56,7 @@ import {
|
||||
applications,
|
||||
compose,
|
||||
environments,
|
||||
libsql,
|
||||
mariadb,
|
||||
mongo,
|
||||
mysql,
|
||||
@@ -138,6 +141,9 @@ export const projectRouter = createTRPCRouter({
|
||||
accessedServices,
|
||||
),
|
||||
},
|
||||
libsql: {
|
||||
where: buildServiceFilter(libsql.libsqlId, accessedServices),
|
||||
},
|
||||
mariadb: {
|
||||
where: buildServiceFilter(
|
||||
mariadb.mariadbId,
|
||||
@@ -161,6 +167,11 @@ export const projectRouter = createTRPCRouter({
|
||||
},
|
||||
},
|
||||
},
|
||||
projectTags: {
|
||||
with: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -222,6 +233,14 @@ export const projectRouter = createTRPCRouter({
|
||||
applicationStatus: true,
|
||||
},
|
||||
},
|
||||
libsql: {
|
||||
where: buildServiceFilter(libsql.libsqlId, accessedServices),
|
||||
columns: {
|
||||
libsqlId: true,
|
||||
name: true,
|
||||
applicationStatus: true,
|
||||
},
|
||||
},
|
||||
mariadb: {
|
||||
where: buildServiceFilter(mariadb.mariadbId, accessedServices),
|
||||
columns: {
|
||||
@@ -280,6 +299,11 @@ export const projectRouter = createTRPCRouter({
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
projectTags: {
|
||||
with: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: desc(projects.createdAt),
|
||||
});
|
||||
@@ -328,6 +352,11 @@ export const projectRouter = createTRPCRouter({
|
||||
composeStatus: true,
|
||||
},
|
||||
},
|
||||
libsql: {
|
||||
columns: {
|
||||
libsqlId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
name: true,
|
||||
@@ -335,6 +364,11 @@ export const projectRouter = createTRPCRouter({
|
||||
isDefault: true,
|
||||
},
|
||||
},
|
||||
projectTags: {
|
||||
with: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: eq(projects.organizationId, ctx.session.activeOrganizationId),
|
||||
orderBy: desc(projects.createdAt),
|
||||
@@ -435,6 +469,17 @@ export const projectRouter = createTRPCRouter({
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
libsql: {
|
||||
columns: {
|
||||
libsqlId: true,
|
||||
appName: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
applicationStatus: true,
|
||||
description: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -607,12 +652,13 @@ export const projectRouter = createTRPCRouter({
|
||||
id: z.string(),
|
||||
type: z.enum([
|
||||
"application",
|
||||
"postgres",
|
||||
"compose",
|
||||
"libsql",
|
||||
"mariadb",
|
||||
"mongo",
|
||||
"mysql",
|
||||
"postgres",
|
||||
"redis",
|
||||
"compose",
|
||||
]),
|
||||
}),
|
||||
)
|
||||
@@ -756,21 +802,27 @@ export const projectRouter = createTRPCRouter({
|
||||
|
||||
break;
|
||||
}
|
||||
case "postgres": {
|
||||
const { postgresId, mounts, backups, appName, ...postgres } =
|
||||
await findPostgresById(id);
|
||||
case "compose": {
|
||||
const {
|
||||
composeId,
|
||||
mounts,
|
||||
domains,
|
||||
appName,
|
||||
refreshToken,
|
||||
...compose
|
||||
} = await findComposeById(id);
|
||||
|
||||
const newAppName = appName.substring(
|
||||
0,
|
||||
appName.lastIndexOf("-"),
|
||||
);
|
||||
|
||||
const newPostgres = await createPostgres({
|
||||
...postgres,
|
||||
const newCompose = await createCompose({
|
||||
...compose,
|
||||
appName: newAppName,
|
||||
name: input.duplicateInSameProject
|
||||
? `${postgres.name} (copy)`
|
||||
: postgres.name,
|
||||
? `${compose.name} (copy)`
|
||||
: compose.name,
|
||||
environmentId: targetProject?.environmentId || "",
|
||||
});
|
||||
|
||||
@@ -778,18 +830,49 @@ export const projectRouter = createTRPCRouter({
|
||||
const { mountId, ...rest } = mount;
|
||||
await createMount({
|
||||
...rest,
|
||||
serviceId: newPostgres.postgresId,
|
||||
serviceType: "postgres",
|
||||
serviceId: newCompose.composeId,
|
||||
serviceType: "compose",
|
||||
});
|
||||
}
|
||||
|
||||
for (const backup of backups) {
|
||||
const { backupId, appName: _appName, ...rest } = backup;
|
||||
await createBackup({
|
||||
for (const domain of domains) {
|
||||
const { domainId, ...rest } = domain;
|
||||
await createDomain({
|
||||
...rest,
|
||||
postgresId: newPostgres.postgresId,
|
||||
composeId: newCompose.composeId,
|
||||
domainType: "compose",
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "libsql": {
|
||||
const { libsqlId, mounts, appName, ...libsql } =
|
||||
await findLibsqlById(id);
|
||||
|
||||
const newAppName = appName.substring(
|
||||
0,
|
||||
appName.lastIndexOf("-"),
|
||||
);
|
||||
|
||||
const newLibsql = await createLibsql({
|
||||
...libsql,
|
||||
appName: newAppName,
|
||||
name: input.duplicateInSameProject
|
||||
? `${libsql.name} (copy)`
|
||||
: libsql.name,
|
||||
environmentId: targetProject?.environmentId || "",
|
||||
});
|
||||
|
||||
for (const mount of mounts) {
|
||||
const { mountId, ...rest } = mount;
|
||||
await createMount({
|
||||
...rest,
|
||||
serviceId: newLibsql.libsqlId,
|
||||
serviceType: "libsql",
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "mariadb": {
|
||||
@@ -900,6 +983,42 @@ export const projectRouter = createTRPCRouter({
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "postgres": {
|
||||
const { postgresId, mounts, backups, appName, ...postgres } =
|
||||
await findPostgresById(id);
|
||||
|
||||
const newAppName = appName.substring(
|
||||
0,
|
||||
appName.lastIndexOf("-"),
|
||||
);
|
||||
|
||||
const newPostgres = await createPostgres({
|
||||
...postgres,
|
||||
appName: newAppName,
|
||||
name: input.duplicateInSameProject
|
||||
? `${postgres.name} (copy)`
|
||||
: postgres.name,
|
||||
environmentId: targetProject?.environmentId || "",
|
||||
});
|
||||
|
||||
for (const mount of mounts) {
|
||||
const { mountId, ...rest } = mount;
|
||||
await createMount({
|
||||
...rest,
|
||||
serviceId: newPostgres.postgresId,
|
||||
serviceType: "postgres",
|
||||
});
|
||||
}
|
||||
|
||||
for (const backup of backups) {
|
||||
const { backupId, ...rest } = backup;
|
||||
await createBackup({
|
||||
...rest,
|
||||
postgresId: newPostgres.postgresId,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "redis": {
|
||||
const { redisId, mounts, appName, ...redis } =
|
||||
await findRedisById(id);
|
||||
@@ -927,50 +1046,6 @@ export const projectRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "compose": {
|
||||
const {
|
||||
composeId,
|
||||
mounts,
|
||||
domains,
|
||||
appName,
|
||||
refreshToken,
|
||||
...compose
|
||||
} = await findComposeById(id);
|
||||
|
||||
const newAppName = appName.substring(
|
||||
0,
|
||||
appName.lastIndexOf("-"),
|
||||
);
|
||||
|
||||
const newCompose = await createCompose({
|
||||
...compose,
|
||||
appName: newAppName,
|
||||
name: input.duplicateInSameProject
|
||||
? `${compose.name} (copy)`
|
||||
: compose.name,
|
||||
environmentId: targetProject?.environmentId || "",
|
||||
});
|
||||
|
||||
for (const mount of mounts) {
|
||||
const { mountId, ...rest } = mount;
|
||||
await createMount({
|
||||
...rest,
|
||||
serviceId: newCompose.composeId,
|
||||
serviceType: "compose",
|
||||
});
|
||||
}
|
||||
|
||||
for (const domain of domains) {
|
||||
const { domainId, ...rest } = domain;
|
||||
await createDomain({
|
||||
...rest,
|
||||
composeId: newCompose.composeId,
|
||||
domainType: "compose",
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +252,8 @@ export const serverRouter = createTRPCRouter({
|
||||
isDokployNetworkInstalled: boolean;
|
||||
isSwarmInstalled: boolean;
|
||||
isMainDirectoryInstalled: boolean;
|
||||
privilegeMode: string;
|
||||
dockerGroupMember: boolean;
|
||||
};
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -2,6 +2,9 @@ import {
|
||||
CLEANUP_CRON_JOB,
|
||||
checkGPUStatus,
|
||||
checkPortInUse,
|
||||
checkPostgresHealth,
|
||||
checkRedisHealth,
|
||||
checkTraefikHealth,
|
||||
cleanupAll,
|
||||
cleanupAllBackground,
|
||||
cleanupBuilders,
|
||||
@@ -44,8 +47,8 @@ import {
|
||||
writeTraefikConfigInPath,
|
||||
writeTraefikSetup,
|
||||
} from "@dokploy/server";
|
||||
import { checkPermission } from "@dokploy/server/services/permission";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { checkPermission } from "@dokploy/server/services/permission";
|
||||
import { generateOpenApiDocument } from "@dokploy/trpc-openapi";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
@@ -645,6 +648,7 @@ export const settingsRouter = createTRPCRouter({
|
||||
"postgres",
|
||||
"redis",
|
||||
"mongo",
|
||||
"libsql",
|
||||
"mariadb",
|
||||
"sshRouter",
|
||||
"gitProvider",
|
||||
@@ -653,6 +657,18 @@ export const settingsRouter = createTRPCRouter({
|
||||
"github",
|
||||
"gitlab",
|
||||
"gitea",
|
||||
"tag",
|
||||
"patch",
|
||||
"server",
|
||||
"volumeBackups",
|
||||
"environment",
|
||||
"auditLog",
|
||||
"customRole",
|
||||
"whitelabeling",
|
||||
"sso",
|
||||
"licenseKey",
|
||||
"organization",
|
||||
"previewDeployment",
|
||||
],
|
||||
});
|
||||
|
||||
@@ -864,6 +880,23 @@ export const settingsRouter = createTRPCRouter({
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
checkInfrastructureHealth: adminProcedure.query(async () => {
|
||||
if (IS_CLOUD) {
|
||||
return {
|
||||
postgres: { status: "healthy" as const },
|
||||
redis: { status: "healthy" as const },
|
||||
traefik: { status: "healthy" as const },
|
||||
};
|
||||
}
|
||||
|
||||
const [postgres, redis, traefik] = await Promise.all([
|
||||
checkPostgresHealth(),
|
||||
checkRedisHealth(),
|
||||
checkTraefikHealth(),
|
||||
]);
|
||||
|
||||
return { postgres, redis, traefik };
|
||||
}),
|
||||
setupGPU: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
439
apps/dokploy/server/api/routers/tag.ts
Normal file
439
apps/dokploy/server/api/routers/tag.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
import { findMemberByUserId } from "@dokploy/server/services/permission";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/server/db";
|
||||
import {
|
||||
apiCreateTag,
|
||||
apiFindOneTag,
|
||||
apiRemoveTag,
|
||||
apiUpdateTag,
|
||||
projects,
|
||||
projectTags,
|
||||
tags,
|
||||
} from "@/server/db/schema";
|
||||
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
|
||||
|
||||
export const tagRouter = createTRPCRouter({
|
||||
create: withPermission("tag", "create")
|
||||
.input(apiCreateTag)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const newTag = await db
|
||||
.insert(tags)
|
||||
.values({
|
||||
name: input.name,
|
||||
color: input.color,
|
||||
organizationId: ctx.session.activeOrganizationId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return newTag[0];
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes("unique_org_tag_name")
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "A tag with this name already exists in your organization",
|
||||
});
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Error creating tag: ${error instanceof Error ? error.message : error}`,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
all: protectedProcedure.query(async ({ ctx }) => {
|
||||
try {
|
||||
const organizationTags = await db.query.tags.findMany({
|
||||
where: eq(tags.organizationId, ctx.session.activeOrganizationId),
|
||||
orderBy: (tags, { asc }) => [asc(tags.name)],
|
||||
});
|
||||
|
||||
return organizationTags;
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Error fetching tags: ${error instanceof Error ? error.message : error}`,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
one: protectedProcedure.input(apiFindOneTag).query(async ({ input, ctx }) => {
|
||||
try {
|
||||
const tag = await db.query.tags.findFirst({
|
||||
where: and(
|
||||
eq(tags.tagId, input.tagId),
|
||||
eq(tags.organizationId, ctx.session.activeOrganizationId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!tag) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Tag not found",
|
||||
});
|
||||
}
|
||||
|
||||
return tag;
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Error fetching tag: ${error instanceof Error ? error.message : error}`,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
update: withPermission("tag", "update")
|
||||
.input(apiUpdateTag)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
// First verify the tag belongs to the user's organization
|
||||
const existingTag = await db.query.tags.findFirst({
|
||||
where: and(
|
||||
eq(tags.tagId, input.tagId),
|
||||
eq(tags.organizationId, ctx.session.activeOrganizationId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!existingTag) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Tag not found or you don't have permission to update it",
|
||||
});
|
||||
}
|
||||
|
||||
const updatedTag = await db
|
||||
.update(tags)
|
||||
.set({
|
||||
...(input.name !== undefined && { name: input.name }),
|
||||
...(input.color !== undefined && { color: input.color }),
|
||||
})
|
||||
.where(eq(tags.tagId, input.tagId))
|
||||
.returning();
|
||||
|
||||
return updatedTag[0];
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes("unique_org_tag_name")
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "A tag with this name already exists in your organization",
|
||||
});
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Error updating tag: ${error instanceof Error ? error.message : error}`,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
remove: withPermission("tag", "delete")
|
||||
.input(apiRemoveTag)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
// First verify the tag belongs to the user's organization
|
||||
const existingTag = await db.query.tags.findFirst({
|
||||
where: and(
|
||||
eq(tags.tagId, input.tagId),
|
||||
eq(tags.organizationId, ctx.session.activeOrganizationId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!existingTag) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Tag not found or you don't have permission to delete it",
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the tag - cascade delete will handle projectTags associations
|
||||
await db.delete(tags).where(eq(tags.tagId, input.tagId));
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Error deleting tag: ${error instanceof Error ? error.message : error}`,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
assignToProject: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string().min(1),
|
||||
tagId: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const memberRecord = await findMemberByUserId(
|
||||
ctx.user.id,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
|
||||
// Verify the project belongs to the user's organization
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: and(
|
||||
eq(projects.projectId, input.projectId),
|
||||
eq(projects.organizationId, ctx.session.activeOrganizationId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message:
|
||||
"Project not found or you don't have permission to modify it",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the member has access to the project
|
||||
if (
|
||||
memberRecord.role !== "owner" &&
|
||||
memberRecord.role !== "admin" &&
|
||||
!memberRecord.accessedProjects.includes(input.projectId)
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this project",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the tag belongs to the user's organization
|
||||
const tag = await db.query.tags.findFirst({
|
||||
where: and(
|
||||
eq(tags.tagId, input.tagId),
|
||||
eq(tags.organizationId, ctx.session.activeOrganizationId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!tag) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Tag not found or you don't have permission to use it",
|
||||
});
|
||||
}
|
||||
|
||||
// Insert the project-tag association
|
||||
const newAssociation = await db
|
||||
.insert(projectTags)
|
||||
.values({
|
||||
projectId: input.projectId,
|
||||
tagId: input.tagId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return newAssociation[0];
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes("unique_project_tag")
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "This tag is already assigned to this project",
|
||||
});
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Error assigning tag to project: ${error instanceof Error ? error.message : error}`,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
removeFromProject: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string().min(1),
|
||||
tagId: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const memberRecord = await findMemberByUserId(
|
||||
ctx.user.id,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
|
||||
// Verify the project belongs to the user's organization
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: and(
|
||||
eq(projects.projectId, input.projectId),
|
||||
eq(projects.organizationId, ctx.session.activeOrganizationId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message:
|
||||
"Project not found or you don't have permission to modify it",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the member has access to the project
|
||||
if (
|
||||
memberRecord.role !== "owner" &&
|
||||
memberRecord.role !== "admin" &&
|
||||
!memberRecord.accessedProjects.includes(input.projectId)
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this project",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the tag belongs to the user's organization
|
||||
const tag = await db.query.tags.findFirst({
|
||||
where: and(
|
||||
eq(tags.tagId, input.tagId),
|
||||
eq(tags.organizationId, ctx.session.activeOrganizationId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!tag) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Tag not found or you don't have permission to use it",
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the project-tag association
|
||||
await db
|
||||
.delete(projectTags)
|
||||
.where(
|
||||
and(
|
||||
eq(projectTags.projectId, input.projectId),
|
||||
eq(projectTags.tagId, input.tagId),
|
||||
),
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Error removing tag from project: ${error instanceof Error ? error.message : error}`,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
bulkAssign: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
projectId: z.string().min(1),
|
||||
tagIds: z.array(z.string().min(1)),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const memberRecord = await findMemberByUserId(
|
||||
ctx.user.id,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
|
||||
// Verify the project belongs to the user's organization
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: and(
|
||||
eq(projects.projectId, input.projectId),
|
||||
eq(projects.organizationId, ctx.session.activeOrganizationId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message:
|
||||
"Project not found or you don't have permission to modify it",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the member has access to the project
|
||||
if (
|
||||
memberRecord.role !== "owner" &&
|
||||
memberRecord.role !== "admin" &&
|
||||
!memberRecord.accessedProjects.includes(input.projectId)
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this project",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify all tags belong to the user's organization
|
||||
if (input.tagIds.length > 0) {
|
||||
const tagCount = await db.query.tags.findMany({
|
||||
where: and(
|
||||
eq(tags.organizationId, ctx.session.activeOrganizationId),
|
||||
),
|
||||
});
|
||||
|
||||
const validTagIds = tagCount.map((tag) => tag.tagId);
|
||||
const invalidTags = input.tagIds.filter(
|
||||
(id) => !validTagIds.includes(id),
|
||||
);
|
||||
|
||||
if (invalidTags.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "One or more tags not found in your organization",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all existing tag associations for this project
|
||||
await db
|
||||
.delete(projectTags)
|
||||
.where(eq(projectTags.projectId, input.projectId));
|
||||
|
||||
// Insert new tag associations
|
||||
if (input.tagIds.length > 0) {
|
||||
await db.insert(projectTags).values(
|
||||
input.tagIds.map((tagId) => ({
|
||||
projectId: input.projectId,
|
||||
tagId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Error bulk assigning tags to project: ${error instanceof Error ? error.message : error}`,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
updateVolumeBackupSchema,
|
||||
volumeBackups,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
|
||||
import {
|
||||
execAsyncRemote,
|
||||
execAsyncStream,
|
||||
@@ -25,7 +26,6 @@ import { desc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
|
||||
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
|
||||
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
|
||||
|
||||
export const volumeBackupsRouter = createTRPCRouter({
|
||||
@@ -41,6 +41,7 @@ export const volumeBackupsRouter = createTRPCRouter({
|
||||
"mongo",
|
||||
"redis",
|
||||
"compose",
|
||||
"libsql",
|
||||
]),
|
||||
}),
|
||||
)
|
||||
@@ -58,6 +59,7 @@ export const volumeBackupsRouter = createTRPCRouter({
|
||||
mongo: true,
|
||||
redis: true,
|
||||
compose: true,
|
||||
libsql: true,
|
||||
},
|
||||
orderBy: [desc(volumeBackups.createdAt)],
|
||||
});
|
||||
@@ -72,6 +74,7 @@ export const volumeBackupsRouter = createTRPCRouter({
|
||||
input.mariadbId ||
|
||||
input.mongoId ||
|
||||
input.redisId ||
|
||||
input.libsqlId ||
|
||||
input.composeId;
|
||||
if (serviceId) {
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
@@ -113,6 +116,7 @@ export const volumeBackupsRouter = createTRPCRouter({
|
||||
vb.mariadbId ||
|
||||
vb.mongoId ||
|
||||
vb.redisId ||
|
||||
vb.libsqlId ||
|
||||
vb.composeId;
|
||||
if (serviceId) {
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
@@ -136,6 +140,7 @@ export const volumeBackupsRouter = createTRPCRouter({
|
||||
vb.mariadbId ||
|
||||
vb.mongoId ||
|
||||
vb.redisId ||
|
||||
vb.libsqlId ||
|
||||
vb.composeId;
|
||||
if (serviceId) {
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
@@ -161,6 +166,7 @@ export const volumeBackupsRouter = createTRPCRouter({
|
||||
existingVb.mariadbId ||
|
||||
existingVb.mongoId ||
|
||||
existingVb.redisId ||
|
||||
existingVb.libsqlId ||
|
||||
existingVb.composeId;
|
||||
if (serviceId) {
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
@@ -220,6 +226,7 @@ export const volumeBackupsRouter = createTRPCRouter({
|
||||
vb.mariadbId ||
|
||||
vb.mongoId ||
|
||||
vb.redisId ||
|
||||
vb.libsqlId ||
|
||||
vb.composeId;
|
||||
if (serviceId) {
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user