mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-12 09:25:24 +02:00
Compare commits
1 Commits
fix/webhoo
...
dosu/doc-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d287e2a56d |
1
.github/workflows/pr-quality.yml
vendored
1
.github/workflows/pr-quality.yml
vendored
@@ -16,6 +16,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: peakoss/anti-slop@v0
|
- uses: peakoss/anti-slop@v0
|
||||||
with:
|
with:
|
||||||
|
max-failures: 4
|
||||||
blocked-commit-authors: "claude,copilot"
|
blocked-commit-authors: "claude,copilot"
|
||||||
require-description: true
|
require-description: true
|
||||||
min-account-age: 5
|
min-account-age: 5
|
||||||
|
|||||||
@@ -99,14 +99,7 @@ pnpm run dokploy:build
|
|||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
To build the docker image first run commands to copy .env files
|
To build the docker image
|
||||||
|
|
||||||
```bash
|
|
||||||
cp apps/dokploy/.env.production.example .env.production
|
|
||||||
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
|
||||||
```
|
|
||||||
|
|
||||||
then run build command
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm run docker:build
|
pnpm run docker:build
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies th
|
|||||||
Dokploy includes multiple features to make your life easier.
|
Dokploy includes multiple features to make your life easier.
|
||||||
|
|
||||||
- **Applications**: Deploy any type of application (Node.js, PHP, Python, Go, Ruby, etc.).
|
- **Applications**: Deploy any type of application (Node.js, PHP, Python, Go, Ruby, etc.).
|
||||||
- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, libsql, and Redis.
|
- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, and Redis.
|
||||||
- **Backups**: Automate backups for databases to an external storage destination.
|
- **Backups**: Automate backups for databases to an external storage destination.
|
||||||
- **Docker Compose**: Native support for Docker Compose to manage complex applications.
|
- **Docker Compose**: Native support for Docker Compose to manage complex applications.
|
||||||
- **Multi Node**: Scale applications to multiple nodes using Docker Swarm to manage the cluster.
|
- **Multi Node**: Scale applications to multiple nodes using Docker Swarm to manage the cluster.
|
||||||
@@ -39,7 +39,7 @@ To get started, run the following command on a VPS:
|
|||||||
Want to skip the installation process? [Try the Dokploy Cloud](https://app.dokploy.com).
|
Want to skip the installation process? [Try the Dokploy Cloud](https://app.dokploy.com).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sSL https://dokploy.com/install.sh | bash
|
curl -sSL https://dokploy.com/install.sh | sh
|
||||||
```
|
```
|
||||||
|
|
||||||
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
||||||
|
|||||||
@@ -6,3 +6,377 @@ npm run dev
|
|||||||
```
|
```
|
||||||
open http://localhost:3000
|
open http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
The API server requires the following environment variables for configuration:
|
||||||
|
|
||||||
|
### Inngest Configuration
|
||||||
|
|
||||||
|
Required for the GET /jobs endpoint to list deployment jobs:
|
||||||
|
|
||||||
|
- **INNGEST_BASE_URL** - The base URL for the Inngest instance
|
||||||
|
- Self-hosted: `http://localhost:8288`
|
||||||
|
- Production: `https://dev-inngest.dokploy.com`
|
||||||
|
|
||||||
|
- **INNGEST_SIGNING_KEY** - The signing key for authenticating with Inngest
|
||||||
|
|
||||||
|
Optional configuration for filtering and pagination:
|
||||||
|
|
||||||
|
- **INNGEST_EVENTS_RECEIVED_AFTER** (optional) - An RFC3339 timestamp to filter events received after a specific date (e.g., `2024-01-01T00:00:00Z`). If unset, no date filter is applied.
|
||||||
|
|
||||||
|
- **INNGEST_JOBS_MAX_EVENTS** (optional) - Maximum number of events to fetch when listing jobs. Default is 100, maximum is 10000. Used for pagination with cursor.
|
||||||
|
|
||||||
|
### Lemon Squeezy Integration
|
||||||
|
|
||||||
|
- **LEMON_SQUEEZY_API_KEY** - API key for Lemon Squeezy integration
|
||||||
|
- **LEMON_SQUEEZY_STORE_ID** - Store ID for Lemon Squeezy integration
|
||||||
|
|
||||||
|
### Docker Configuration
|
||||||
|
|
||||||
|
Optional configuration for customizing Docker daemon connections:
|
||||||
|
|
||||||
|
- **DOCKER_API_VERSION** (optional) - Specifies which Docker API version to use when connecting to the Docker daemon. If not set, the Docker client uses the default API version.
|
||||||
|
|
||||||
|
- **DOKPLOY_DOCKER_HOST** (optional) - Specifies the Docker daemon host to connect to. If not set, uses the default Docker socket connection.
|
||||||
|
|
||||||
|
- **DOKPLOY_DOCKER_PORT** (optional) - Specifies the port for connecting to the Docker daemon. If not set, uses the default port.
|
||||||
|
|
||||||
|
These variables allow advanced users to customize how the Dokploy API server connects to Docker, which can be useful for connecting to remote Docker daemons or using specific API versions.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### GET /jobs
|
||||||
|
|
||||||
|
Lists deployment jobs (Inngest runs) for a specified server.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `serverId` (required) - The ID of the server to list deployment jobs for
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
Returns an array of deployment job objects with the same shape as BullMQ queue jobs:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "string",
|
||||||
|
"name": "string",
|
||||||
|
"data": {},
|
||||||
|
"timestamp": 0,
|
||||||
|
"processedOn": 0,
|
||||||
|
"finishedOn": 0,
|
||||||
|
"failedReason": "string",
|
||||||
|
"state": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
- `400` - serverId is not provided
|
||||||
|
- `503` - INNGEST_BASE_URL is not configured
|
||||||
|
- `200` - Empty array on other errors
|
||||||
|
|
||||||
|
This endpoint is used by the UI to display deployment queue information in the dashboard.
|
||||||
|
|
||||||
|
## Search Endpoints
|
||||||
|
|
||||||
|
The following search endpoints provide flexible querying capabilities with pagination support. All search endpoints respect member permissions, returning only resources the user has access to.
|
||||||
|
|
||||||
|
### application.search
|
||||||
|
|
||||||
|
Search applications across name, appName, description, repository, owner, and dockerImage fields.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `q` (optional string) - General search term that searches across name, appName, description, repository, owner, and dockerImage
|
||||||
|
- `name` (optional string) - Filter by application name
|
||||||
|
- `appName` (optional string) - Filter by app name
|
||||||
|
- `description` (optional string) - Filter by description
|
||||||
|
- `repository` (optional string) - Filter by repository
|
||||||
|
- `owner` (optional string) - Filter by owner
|
||||||
|
- `dockerImage` (optional string) - Filter by Docker image
|
||||||
|
- `projectId` (optional string) - Filter by project ID
|
||||||
|
- `environmentId` (optional string) - Filter by environment ID
|
||||||
|
- `limit` (number, default 20, min 1, max 100) - Maximum number of results
|
||||||
|
- `offset` (number, default 0, min 0) - Pagination offset
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"applicationId": "string",
|
||||||
|
"name": "string",
|
||||||
|
"appName": "string",
|
||||||
|
"description": "string",
|
||||||
|
"environmentId": "string",
|
||||||
|
"applicationStatus": "string",
|
||||||
|
"sourceType": "string",
|
||||||
|
"createdAt": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### compose.search
|
||||||
|
|
||||||
|
Search compose services with filtering by name, appName, and description.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `q` (optional string) - General search term across name, appName, description
|
||||||
|
- `name` (optional string) - Filter by name
|
||||||
|
- `appName` (optional string) - Filter by app name
|
||||||
|
- `description` (optional string) - Filter by description
|
||||||
|
- `projectId` (optional string) - Filter by project ID
|
||||||
|
- `environmentId` (optional string) - Filter by environment ID
|
||||||
|
- `limit` (number, default 20, min 1, max 100) - Maximum results
|
||||||
|
- `offset` (number, default 0, min 0) - Pagination offset
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"composeId": "string",
|
||||||
|
"name": "string",
|
||||||
|
"appName": "string",
|
||||||
|
"description": "string",
|
||||||
|
"environmentId": "string",
|
||||||
|
"composeStatus": "string",
|
||||||
|
"sourceType": "string",
|
||||||
|
"createdAt": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### environment.search
|
||||||
|
|
||||||
|
Search environments by name and description.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `q` (optional string) - General search term across name and description
|
||||||
|
- `name` (optional string) - Filter by name
|
||||||
|
- `description` (optional string) - Filter by description
|
||||||
|
- `projectId` (optional string) - Filter by project ID
|
||||||
|
- `limit` (number, default 20, min 1, max 100) - Maximum results
|
||||||
|
- `offset` (number, default 0, min 0) - Pagination offset
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"environmentId": "string",
|
||||||
|
"name": "string",
|
||||||
|
"description": "string",
|
||||||
|
"createdAt": "string",
|
||||||
|
"env": "string",
|
||||||
|
"projectId": "string",
|
||||||
|
"isDefault": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### project.search
|
||||||
|
|
||||||
|
Search projects by name and description.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `q` (optional string) - General search term across name and description
|
||||||
|
- `name` (optional string) - Filter by name
|
||||||
|
- `description` (optional string) - Filter by description
|
||||||
|
- `limit` (number, default 20, min 1, max 100) - Maximum results
|
||||||
|
- `offset` (number, default 0, min 0) - Pagination offset
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"projectId": "string",
|
||||||
|
"name": "string",
|
||||||
|
"description": "string",
|
||||||
|
"createdAt": "string",
|
||||||
|
"organizationId": "string",
|
||||||
|
"env": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Service Search Endpoints
|
||||||
|
|
||||||
|
The following database services all share the same search interface:
|
||||||
|
- **postgres.search**
|
||||||
|
- **mysql.search**
|
||||||
|
- **mariadb.search**
|
||||||
|
- **mongo.search**
|
||||||
|
- **redis.search**
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `q` (optional string) - General search term across name, appName, description
|
||||||
|
- `name` (optional string) - Filter by name
|
||||||
|
- `appName` (optional string) - Filter by app name
|
||||||
|
- `description` (optional string) - Filter by description
|
||||||
|
- `projectId` (optional string) - Filter by project ID
|
||||||
|
- `environmentId` (optional string) - Filter by environment ID
|
||||||
|
- `limit` (number, default 20, min 1, max 100) - Maximum results
|
||||||
|
- `offset` (number, default 0, min 0) - Pagination offset
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"postgresId": "string",
|
||||||
|
"name": "string",
|
||||||
|
"appName": "string",
|
||||||
|
"description": "string",
|
||||||
|
"environmentId": "string",
|
||||||
|
"applicationStatus": "string",
|
||||||
|
"createdAt": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
*Note: The response shape is similar across all database services, with the ID field varying (e.g., `mysqlId`, `mariadbId`, `mongoId`, `redisId`).*
|
||||||
|
|
||||||
|
**Search Behavior:**
|
||||||
|
- All searches use case-insensitive pattern matching with wildcards
|
||||||
|
- Results are ordered by creation date (descending)
|
||||||
|
- Members only see services they have access to
|
||||||
|
- Returns total count for pagination UI
|
||||||
|
|
||||||
|
## Database Service Update Endpoints
|
||||||
|
|
||||||
|
The following database services support update operations with similar parameters:
|
||||||
|
- **postgres.update** (apiUpdatePostgres)
|
||||||
|
- **mysql.update** (apiUpdateMySql)
|
||||||
|
- **mariadb.update** (apiUpdateMariaDB)
|
||||||
|
- **mongo.update** (apiUpdateMongo)
|
||||||
|
- **redis.update** (apiUpdateRedis)
|
||||||
|
|
||||||
|
**Input Parameters:**
|
||||||
|
|
||||||
|
Each update endpoint accepts the following parameters (all optional except the service ID):
|
||||||
|
|
||||||
|
- `postgresId` / `mysqlId` / `mariadbId` / `mongoId` / `redisId` (required) - The ID of the database service to update
|
||||||
|
- `dockerImage` (optional string) - Custom Docker image to use for the database service
|
||||||
|
- Other service-specific parameters as defined in the schema
|
||||||
|
|
||||||
|
**Example Input:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"postgresId": "string",
|
||||||
|
"dockerImage": "postgres:16-alpine"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
Returns the updated database service object.
|
||||||
|
|
||||||
|
**Purpose:**
|
||||||
|
The `dockerImage` parameter allows users to specify custom Docker images for database services, providing flexibility for version selection, custom builds, or alternative distributions.
|
||||||
|
|
||||||
|
## Whitelabeling Endpoints
|
||||||
|
|
||||||
|
The whitelabeling endpoints allow enterprise/self-hosted Dokploy instances to customize branding, logos, colors, and UI appearance. These endpoints are only available in self-hosted mode (not cloud).
|
||||||
|
|
||||||
|
### whitelabeling.get
|
||||||
|
|
||||||
|
Get the current whitelabeling configuration.
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Enterprise license required
|
||||||
|
- Only available for self-hosted (not cloud)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
Returns the whitelabeling configuration object or null if not configured.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"appName": "string | null",
|
||||||
|
"appDescription": "string | null",
|
||||||
|
"logoUrl": "string | null",
|
||||||
|
"faviconUrl": "string | null",
|
||||||
|
"primaryColor": "string | null",
|
||||||
|
"customCss": "string | null",
|
||||||
|
"loginLogoUrl": "string | null",
|
||||||
|
"supportUrl": "string | null",
|
||||||
|
"docsUrl": "string | null",
|
||||||
|
"errorPageTitle": "string | null",
|
||||||
|
"errorPageDescription": "string | null",
|
||||||
|
"metaTitle": "string | null",
|
||||||
|
"footerText": "string | null"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### whitelabeling.update
|
||||||
|
|
||||||
|
Update the whitelabeling configuration.
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Enterprise license required
|
||||||
|
- Owner role required
|
||||||
|
- Only available for self-hosted (not cloud)
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"whitelabelingConfig": {
|
||||||
|
"appName": "string | null",
|
||||||
|
"appDescription": "string | null",
|
||||||
|
"logoUrl": "string | null",
|
||||||
|
"faviconUrl": "string | null",
|
||||||
|
"primaryColor": "string | null",
|
||||||
|
"customCss": "string | null",
|
||||||
|
"loginLogoUrl": "string | null",
|
||||||
|
"supportUrl": "string | null",
|
||||||
|
"docsUrl": "string | null",
|
||||||
|
"errorPageTitle": "string | null",
|
||||||
|
"errorPageDescription": "string | null",
|
||||||
|
"metaTitle": "string | null",
|
||||||
|
"footerText": "string | null"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### whitelabeling.reset
|
||||||
|
|
||||||
|
Reset whitelabeling configuration to default values (all fields set to null).
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Enterprise license required
|
||||||
|
- Owner role required
|
||||||
|
- Only available for self-hosted (not cloud)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### whitelabeling.getPublic
|
||||||
|
|
||||||
|
Public endpoint to fetch whitelabeling configuration. This endpoint can be accessed without authentication, allowing the whitelabeling settings to be applied globally (including on the login page before auth).
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- No authentication required
|
||||||
|
- Only available for self-hosted (not cloud)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
Returns the whitelabeling configuration object or null if not configured. Response shape is identical to `whitelabeling.get`.
|
||||||
@@ -32,8 +32,6 @@ describe("Host rule format regression tests", () => {
|
|||||||
previewDeploymentId: "",
|
previewDeploymentId: "",
|
||||||
internalPath: "/",
|
internalPath: "/",
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
customEntrypoint: null,
|
|
||||||
middlewares: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Host rule format validation", () => {
|
describe("Host rule format validation", () => {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ describe("createDomainLabels", () => {
|
|||||||
const baseDomain: Domain = {
|
const baseDomain: Domain = {
|
||||||
host: "example.com",
|
host: "example.com",
|
||||||
port: 8080,
|
port: 8080,
|
||||||
customEntrypoint: null,
|
|
||||||
https: false,
|
https: false,
|
||||||
uniqueConfigKey: 1,
|
uniqueConfigKey: 1,
|
||||||
customCertResolver: null,
|
customCertResolver: null,
|
||||||
@@ -22,7 +21,6 @@ describe("createDomainLabels", () => {
|
|||||||
previewDeploymentId: "",
|
previewDeploymentId: "",
|
||||||
internalPath: "/",
|
internalPath: "/",
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
middlewares: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
it("should create basic labels for web entrypoint", async () => {
|
it("should create basic labels for web entrypoint", async () => {
|
||||||
@@ -173,12 +171,12 @@ describe("createDomainLabels", () => {
|
|||||||
"websecure",
|
"websecure",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Web entrypoint with HTTPS should only have redirect
|
// Web entrypoint should have both middlewares with redirect first
|
||||||
expect(webLabels).toContain(
|
expect(webLabels).toContain(
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,addprefix-test-app-1",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Websecure should have the addprefix middleware
|
// Websecure should only have the addprefix middleware
|
||||||
expect(websecureLabels).toContain(
|
expect(websecureLabels).toContain(
|
||||||
"traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1",
|
"traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1",
|
||||||
);
|
);
|
||||||
@@ -210,9 +208,9 @@ describe("createDomainLabels", () => {
|
|||||||
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Web router with HTTPS should only have redirect
|
// Should have middlewares in correct order: redirect, stripprefix, addprefix
|
||||||
expect(webLabels).toContain(
|
expect(webLabels).toContain(
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,stripprefix-test-app-1,addprefix-test-app-1",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -242,259 +240,4 @@ describe("createDomainLabels", () => {
|
|||||||
"traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
|
"traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should add single custom middleware to router", async () => {
|
|
||||||
const customMiddlewareDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
middlewares: ["auth@file"],
|
|
||||||
};
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
customMiddlewareDomain,
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=auth@file",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add multiple custom middlewares to router", async () => {
|
|
||||||
const customMiddlewareDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
middlewares: ["auth@file", "rate-limit@file"],
|
|
||||||
};
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
customMiddlewareDomain,
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=auth@file,rate-limit@file",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should only have redirect on web router when HTTPS is enabled with custom middlewares", async () => {
|
|
||||||
const combinedDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
middlewares: ["auth@file"],
|
|
||||||
};
|
|
||||||
const labels = await createDomainLabels(appName, combinedDomain, "web");
|
|
||||||
|
|
||||||
// Web router with HTTPS should only redirect, custom middlewares go on websecure
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
|
||||||
);
|
|
||||||
expect(labels).not.toContain("auth@file");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should combine custom middlewares with stripPath middleware (no HTTPS)", async () => {
|
|
||||||
const combinedDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
path: "/api",
|
|
||||||
stripPath: true,
|
|
||||||
middlewares: ["auth@file"],
|
|
||||||
};
|
|
||||||
const labels = await createDomainLabels(appName, combinedDomain, "web");
|
|
||||||
|
|
||||||
// stripprefix should come before custom middleware
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=stripprefix-test-app-1,auth@file",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should only have redirect on web router even with all built-in middlewares and custom middlewares", async () => {
|
|
||||||
const fullDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
path: "/api",
|
|
||||||
stripPath: true,
|
|
||||||
internalPath: "/hello",
|
|
||||||
middlewares: ["auth@file", "rate-limit@file"],
|
|
||||||
};
|
|
||||||
const webLabels = await createDomainLabels(appName, fullDomain, "web");
|
|
||||||
|
|
||||||
// Web router with HTTPS should only redirect
|
|
||||||
expect(webLabels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
|
||||||
);
|
|
||||||
// Middleware definitions should still be present (Traefik needs them registered)
|
|
||||||
expect(webLabels).toContain(
|
|
||||||
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
|
|
||||||
);
|
|
||||||
expect(webLabels).toContain(
|
|
||||||
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
|
||||||
);
|
|
||||||
// But they should NOT be attached to the router
|
|
||||||
expect(webLabels).not.toContain("stripprefix-test-app-1,");
|
|
||||||
expect(webLabels).not.toContain("auth@file");
|
|
||||||
expect(webLabels).not.toContain("rate-limit@file");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should include custom middlewares on websecure entrypoint", async () => {
|
|
||||||
const customMiddlewareDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
middlewares: ["auth@file"],
|
|
||||||
};
|
|
||||||
const websecureLabels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
customMiddlewareDomain,
|
|
||||||
"websecure",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Websecure should have custom middleware but not redirect-to-https
|
|
||||||
expect(websecureLabels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-websecure.middlewares=auth@file",
|
|
||||||
);
|
|
||||||
expect(websecureLabels).not.toContain("redirect-to-https");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should NOT include custom middlewares on web router when HTTPS is enabled (only redirect)", async () => {
|
|
||||||
const domain = {
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
middlewares: ["rate-limit@file", "auth@file"],
|
|
||||||
};
|
|
||||||
const webLabels = await createDomainLabels(appName, domain, "web");
|
|
||||||
|
|
||||||
// Web router with HTTPS should ONLY have redirect, not custom middlewares
|
|
||||||
expect(webLabels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
|
||||||
);
|
|
||||||
expect(webLabels).not.toContain("rate-limit@file");
|
|
||||||
expect(webLabels).not.toContain("auth@file");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create basic labels for custom entrypoint", async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
{ ...baseDomain, customEntrypoint: "custom" },
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
expect(labels).toEqual([
|
|
||||||
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
|
|
||||||
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
|
|
||||||
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
|
|
||||||
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create https labels for custom entrypoint", async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
certificateType: "letsencrypt",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
expect(labels).toEqual([
|
|
||||||
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
|
|
||||||
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
|
|
||||||
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
|
|
||||||
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
|
|
||||||
"traefik.http.routers.test-app-1-custom.tls.certresolver=letsencrypt",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add stripPath middleware for custom entrypoint", async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
path: "/api",
|
|
||||||
stripPath: true,
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
|
|
||||||
);
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add internalPath middleware for custom entrypoint", async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
internalPath: "/hello",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
|
||||||
);
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-custom.middlewares=addprefix-test-app-1",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add path prefix in rule for custom entrypoint", async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
path: "/api",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`) && PathPrefix(`/api`)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should combine all middlewares for custom entrypoint", async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
path: "/api",
|
|
||||||
stripPath: true,
|
|
||||||
internalPath: "/hello",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
|
|
||||||
);
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
|
||||||
);
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not add redirect-to-https for custom entrypoint even with https", async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
https: true,
|
|
||||||
certificateType: "letsencrypt",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
const middlewareLabel = labels.find((l) => l.includes(".middlewares="));
|
|
||||||
// Should not contain redirect-to-https since there's only one router
|
|
||||||
expect(middlewareLabel).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ networks:
|
|||||||
dokploy-network:
|
dokploy-network:
|
||||||
`;
|
`;
|
||||||
|
|
||||||
test("It shouldn't add suffix to dokploy-network", () => {
|
test("It shoudn't add suffix to dokploy-network", () => {
|
||||||
const composeData = parse(composeFile7) as ComposeSpecification;
|
const composeData = parse(composeFile7) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ services:
|
|||||||
- dokploy-network
|
- dokploy-network
|
||||||
`;
|
`;
|
||||||
|
|
||||||
test("It shouldn't add suffix to dokploy-network in services", () => {
|
test("It shoudn't add suffix to dokploy-network in services", () => {
|
||||||
const composeData = parse(composeFile7) as ComposeSpecification;
|
const composeData = parse(composeFile7) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
@@ -241,10 +241,10 @@ services:
|
|||||||
dokploy-network:
|
dokploy-network:
|
||||||
aliases:
|
aliases:
|
||||||
- apid
|
- apid
|
||||||
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
test("It shouldn't add suffix to dokploy-network in services multiples cases", () => {
|
test("It shoudn't add suffix to dokploy-network in services multiples cases", () => {
|
||||||
const composeData = parse(composeFile8) as ComposeSpecification;
|
const composeData = parse(composeFile8) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|||||||
@@ -415,24 +415,5 @@ describe("Docker Image Name and Tag Extraction", () => {
|
|||||||
expect(extractImageTag("my-image:123")).toBe("123");
|
expect(extractImageTag("my-image:123")).toBe("123");
|
||||||
expect(extractImageTag("my-image:1")).toBe("1");
|
expect(extractImageTag("my-image:1")).toBe("1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return 'latest' for registry with port but no tag", () => {
|
|
||||||
expect(extractImageTag("registry.example.com:5000/myimage")).toBe(
|
|
||||||
"latest",
|
|
||||||
);
|
|
||||||
expect(extractImageTag("registry:5000/fedora/httpd")).toBe("latest");
|
|
||||||
expect(extractImageTag("localhost:5000/myapp")).toBe("latest");
|
|
||||||
expect(extractImageTag("my-registry.io:443/org/app")).toBe("latest");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should extract tag from registry with port and tag", () => {
|
|
||||||
expect(extractImageTag("registry:5000/image:tag")).toBe("tag");
|
|
||||||
expect(extractImageTag("registry.example.com:5000/myimage:v2.0")).toBe(
|
|
||||||
"v2.0",
|
|
||||||
);
|
|
||||||
expect(extractImageTag("localhost:5000/app:sha-abc123")).toBe(
|
|
||||||
"sha-abc123",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ const baseApp: ApplicationNested = {
|
|||||||
environmentId: "",
|
environmentId: "",
|
||||||
enabled: null,
|
enabled: null,
|
||||||
env: null,
|
env: null,
|
||||||
icon: null,
|
|
||||||
healthCheckSwarm: null,
|
healthCheckSwarm: null,
|
||||||
labelsSwarm: null,
|
labelsSwarm: null,
|
||||||
memoryLimit: null,
|
memoryLimit: null,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getEnvironmentVariablesObject } from "@dokploy/server/index";
|
import { getEnviromentVariablesObject } from "@dokploy/server/index";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const projectEnv = `
|
const projectEnv = `
|
||||||
@@ -15,7 +15,7 @@ DATABASE_NAME=dev_database
|
|||||||
SECRET_KEY=env-secret-123
|
SECRET_KEY=env-secret-123
|
||||||
`;
|
`;
|
||||||
|
|
||||||
describe("getEnvironmentVariablesObject with environment variables (Stack compose)", () => {
|
describe("getEnviromentVariablesObject with environment variables (Stack compose)", () => {
|
||||||
it("resolves environment variables correctly for Stack compose", () => {
|
it("resolves environment variables correctly for Stack compose", () => {
|
||||||
const serviceEnv = `
|
const serviceEnv = `
|
||||||
FOO=\${{environment.NODE_ENV}}
|
FOO=\${{environment.NODE_ENV}}
|
||||||
@@ -23,7 +23,7 @@ BAR=\${{environment.API_URL}}
|
|||||||
BAZ=test
|
BAZ=test
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = getEnvironmentVariablesObject(
|
const result = getEnviromentVariablesObject(
|
||||||
serviceEnv,
|
serviceEnv,
|
||||||
projectEnv,
|
projectEnv,
|
||||||
environmentEnv,
|
environmentEnv,
|
||||||
@@ -45,7 +45,7 @@ DATABASE_URL=\${{project.DATABASE_URL}}
|
|||||||
SERVICE_PORT=4000
|
SERVICE_PORT=4000
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = getEnvironmentVariablesObject(
|
const result = getEnviromentVariablesObject(
|
||||||
serviceEnv,
|
serviceEnv,
|
||||||
projectEnv,
|
projectEnv,
|
||||||
environmentEnv,
|
environmentEnv,
|
||||||
@@ -72,7 +72,7 @@ PASSWORD=secret123
|
|||||||
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
|
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = getEnvironmentVariablesObject(serviceEnv, "", multiRefEnv);
|
const result = getEnviromentVariablesObject(serviceEnv, "", multiRefEnv);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
|
DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
|
||||||
@@ -85,7 +85,7 @@ UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}}
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
expect(() =>
|
expect(() =>
|
||||||
getEnvironmentVariablesObject(serviceWithUndefined, "", environmentEnv),
|
getEnviromentVariablesObject(serviceWithUndefined, "", environmentEnv),
|
||||||
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
|
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ NODE_ENV=production
|
|||||||
API_URL=\${{environment.API_URL}}
|
API_URL=\${{environment.API_URL}}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = getEnvironmentVariablesObject(
|
const result = getEnviromentVariablesObject(
|
||||||
serviceOverrideEnv,
|
serviceOverrideEnv,
|
||||||
"",
|
"",
|
||||||
environmentEnv,
|
environmentEnv,
|
||||||
@@ -115,7 +115,7 @@ SERVICE_NAME=my-service
|
|||||||
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
|
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = getEnvironmentVariablesObject(
|
const result = getEnviromentVariablesObject(
|
||||||
complexServiceEnv,
|
complexServiceEnv,
|
||||||
projectEnv,
|
projectEnv,
|
||||||
environmentEnv,
|
environmentEnv,
|
||||||
@@ -150,7 +150,7 @@ ENV_VAR=\${{environment.API_URL}}
|
|||||||
DB_NAME=\${{environment.DATABASE_NAME}}
|
DB_NAME=\${{environment.DATABASE_NAME}}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = getEnvironmentVariablesObject(
|
const result = getEnviromentVariablesObject(
|
||||||
serviceWithConflicts,
|
serviceWithConflicts,
|
||||||
conflictingProjectEnv,
|
conflictingProjectEnv,
|
||||||
conflictingEnvironmentEnv,
|
conflictingEnvironmentEnv,
|
||||||
@@ -170,7 +170,7 @@ SERVICE_VAR=test
|
|||||||
PROJECT_VAR=\${{project.ENVIRONMENT}}
|
PROJECT_VAR=\${{project.ENVIRONMENT}}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = getEnvironmentVariablesObject(
|
const result = getEnviromentVariablesObject(
|
||||||
serviceWithEmpty,
|
serviceWithEmpty,
|
||||||
projectEnv,
|
projectEnv,
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
enterpriseOnlyResources,
|
enterpriseOnlyResources,
|
||||||
statements,
|
statements,
|
||||||
} from "@dokploy/server/lib/access-control";
|
} from "@dokploy/server/lib/access-control";
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
const FREE_TIER_RESOURCES = [
|
const FREE_TIER_RESOURCES = [
|
||||||
"organization",
|
"organization",
|
||||||
@@ -35,7 +35,6 @@ const ENTERPRISE_RESOURCES = [
|
|||||||
"domain",
|
"domain",
|
||||||
"destination",
|
"destination",
|
||||||
"notification",
|
"notification",
|
||||||
"tag",
|
|
||||||
"logs",
|
"logs",
|
||||||
"monitoring",
|
"monitoring",
|
||||||
"auditLog",
|
"auditLog",
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const createApplication = (
|
|||||||
env: null,
|
env: null,
|
||||||
},
|
},
|
||||||
replicas: 1,
|
replicas: 1,
|
||||||
stopGracePeriodSwarm: 0,
|
stopGracePeriodSwarm: 0n,
|
||||||
ulimitsSwarm: null,
|
ulimitsSwarm: null,
|
||||||
serverId: "server-id",
|
serverId: "server-id",
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -76,8 +76,8 @@ describe("mechanizeDockerContainer", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes stopGracePeriodSwarm as a number and keeps zero values", async () => {
|
it("converts bigint stopGracePeriodSwarm to a number and keeps zero values", async () => {
|
||||||
const application = createApplication({ stopGracePeriodSwarm: 0 });
|
const application = createApplication({ stopGracePeriodSwarm: 0n });
|
||||||
|
|
||||||
await mechanizeDockerContainer(application);
|
await mechanizeDockerContainer(application);
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ const baseApp: ApplicationNested = {
|
|||||||
dropBuildPath: null,
|
dropBuildPath: null,
|
||||||
enabled: null,
|
enabled: null,
|
||||||
env: null,
|
env: null,
|
||||||
icon: null,
|
|
||||||
healthCheckSwarm: null,
|
healthCheckSwarm: null,
|
||||||
labelsSwarm: null,
|
labelsSwarm: null,
|
||||||
memoryLimit: null,
|
memoryLimit: null,
|
||||||
@@ -138,7 +137,6 @@ const baseDomain: Domain = {
|
|||||||
https: false,
|
https: false,
|
||||||
path: null,
|
path: null,
|
||||||
port: null,
|
port: null,
|
||||||
customEntrypoint: null,
|
|
||||||
serviceName: "",
|
serviceName: "",
|
||||||
composeId: "",
|
composeId: "",
|
||||||
customCertResolver: null,
|
customCertResolver: null,
|
||||||
@@ -147,7 +145,6 @@ const baseDomain: Domain = {
|
|||||||
previewDeploymentId: "",
|
previewDeploymentId: "",
|
||||||
internalPath: "/",
|
internalPath: "/",
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
middlewares: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseRedirect: Redirect = {
|
const baseRedirect: Redirect = {
|
||||||
@@ -267,80 +264,6 @@ test("Websecure entrypoint on https domain with redirect", async () => {
|
|||||||
expect(router.middlewares).toContain("redirect-test-1");
|
expect(router.middlewares).toContain("redirect-test-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Custom Middlewares */
|
|
||||||
|
|
||||||
test("Web entrypoint with single custom middleware", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, middlewares: ["auth@file"] },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.middlewares).toContain("auth@file");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Web entrypoint with multiple custom middlewares", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, middlewares: ["auth@file", "rate-limit@file"] },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.middlewares).toContain("auth@file");
|
|
||||||
expect(router.middlewares).toContain("rate-limit@file");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Web entrypoint on https domain with custom middleware", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should only have HTTPS redirect - custom middleware applies on websecure
|
|
||||||
expect(router.middlewares).toContain("redirect-to-https");
|
|
||||||
expect(router.middlewares).not.toContain("auth@file");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Websecure entrypoint with custom middleware", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
|
|
||||||
"websecure",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should have custom middleware but not HTTPS redirect
|
|
||||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
|
||||||
expect(router.middlewares).toContain("auth@file");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Web entrypoint with redirect and custom middleware", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
{
|
|
||||||
...baseApp,
|
|
||||||
appName: "test",
|
|
||||||
redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }],
|
|
||||||
},
|
|
||||||
{ ...baseDomain, middlewares: ["auth@file"] },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should have both redirect middleware and custom middleware
|
|
||||||
expect(router.middlewares).toContain("redirect-test-1");
|
|
||||||
expect(router.middlewares).toContain("auth@file");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Web entrypoint with empty middlewares array", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, https: false, middlewares: [] },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should behave same as no middlewares - no redirect for http
|
|
||||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Certificates */
|
/** Certificates */
|
||||||
|
|
||||||
test("CertificateType on websecure entrypoint", async () => {
|
test("CertificateType on websecure entrypoint", async () => {
|
||||||
@@ -353,110 +276,6 @@ test("CertificateType on websecure entrypoint", async () => {
|
|||||||
expect(router.tls?.certResolver).toBe("letsencrypt");
|
expect(router.tls?.certResolver).toBe("letsencrypt");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Custom entrypoint on http domain", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, https: false, customEntrypoint: "custom" },
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.entryPoints).toEqual(["custom"]);
|
|
||||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
|
||||||
expect(router.tls).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Custom entrypoint on https domain", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
certificateType: "letsencrypt",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.entryPoints).toEqual(["custom"]);
|
|
||||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
|
||||||
expect(router.tls?.certResolver).toBe("letsencrypt");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Custom entrypoint with path includes PathPrefix in rule", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, customEntrypoint: "custom", path: "/api" },
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.rule).toContain("PathPrefix(`/api`)");
|
|
||||||
expect(router.entryPoints).toEqual(["custom"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Custom entrypoint with stripPath adds stripprefix middleware", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
path: "/api",
|
|
||||||
stripPath: true,
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.middlewares).toContain("stripprefix--1");
|
|
||||||
expect(router.entryPoints).toEqual(["custom"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Custom entrypoint with internalPath adds addprefix middleware", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
internalPath: "/hello",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.middlewares).toContain("addprefix--1");
|
|
||||||
expect(router.entryPoints).toEqual(["custom"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Custom entrypoint with https and custom cert resolver", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
certificateType: "custom",
|
|
||||||
customCertResolver: "myresolver",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.entryPoints).toEqual(["custom"]);
|
|
||||||
expect(router.tls?.certResolver).toBe("myresolver");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Custom entrypoint without https should not have tls", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
https: false,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
certificateType: "letsencrypt",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.entryPoints).toEqual(["custom"]);
|
|
||||||
expect(router.tls).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
/** IDN/Punycode */
|
/** IDN/Punycode */
|
||||||
|
|
||||||
test("Internationalized domain name is converted to punycode", async () => {
|
test("Internationalized domain name is converted to punycode", async () => {
|
||||||
|
|||||||
@@ -112,21 +112,14 @@ const menuItems: MenuItem[] = [
|
|||||||
|
|
||||||
const hasStopGracePeriodSwarm = (
|
const hasStopGracePeriodSwarm = (
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): value is { stopGracePeriodSwarm: number | string | null } =>
|
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
|
||||||
typeof value === "object" &&
|
typeof value === "object" &&
|
||||||
value !== null &&
|
value !== null &&
|
||||||
"stopGracePeriodSwarm" in value;
|
"stopGracePeriodSwarm" in value;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "application"
|
|
||||||
| "libsql"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "postgres"
|
|
||||||
| "redis";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddSwarmSettings = ({ id, type }: Props) => {
|
export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||||
|
|||||||
@@ -37,27 +37,27 @@ import { AddSwarmSettings } from "./modify-swarm-settings";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
type: "application" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis";
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
}
|
}
|
||||||
|
|
||||||
const AddRedirectSchema = z.object({
|
const AddRedirectchema = z.object({
|
||||||
replicas: z.number().min(1, "Replicas must be at least 1"),
|
replicas: z.number().min(1, "Replicas must be at least 1"),
|
||||||
registryId: z.string().optional(),
|
registryId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type AddCommand = z.infer<typeof AddRedirectSchema>;
|
type AddCommand = z.infer<typeof AddRedirectchema>;
|
||||||
|
|
||||||
export const ShowClusterSettings = ({ id, type }: Props) => {
|
export const ShowClusterSettings = ({ id, type }: Props) => {
|
||||||
const queryMap = {
|
const queryMap = {
|
||||||
application: () =>
|
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
|
||||||
mariadb: () =>
|
|
||||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
|
||||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
|
||||||
postgres: () =>
|
postgres: () =>
|
||||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -65,13 +65,12 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
|||||||
const { data: registries } = api.registry.all.useQuery();
|
const { data: registries } = api.registry.all.useQuery();
|
||||||
|
|
||||||
const mutationMap = {
|
const mutationMap = {
|
||||||
application: () => api.application.update.useMutation(),
|
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
mariadb: () => api.mariadb.update.useMutation(),
|
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
|
||||||
mysql: () => api.mysql.update.useMutation(),
|
|
||||||
postgres: () => api.postgres.update.useMutation(),
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
redis: () => api.redis.update.useMutation(),
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
application: () => api.application.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync, isPending } = mutationMap[type]
|
const { mutateAsync, isPending } = mutationMap[type]
|
||||||
@@ -87,7 +86,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
|||||||
: {}),
|
: {}),
|
||||||
replicas: data?.replicas || 1,
|
replicas: data?.replicas || 1,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(AddRedirectSchema),
|
resolver: zodResolver(AddRedirectchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -106,11 +105,11 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
|||||||
const onSubmit = async (data: AddCommand) => {
|
const onSubmit = async (data: AddCommand) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
applicationId: id || "",
|
applicationId: id || "",
|
||||||
mariadbId: id || "",
|
|
||||||
mongoId: id || "",
|
|
||||||
mysqlId: id || "",
|
|
||||||
postgresId: id || "",
|
postgresId: id || "",
|
||||||
redisId: id || "",
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
...(type === "application"
|
...(type === "application"
|
||||||
? {
|
? {
|
||||||
registryId:
|
registryId:
|
||||||
|
|||||||
@@ -28,14 +28,7 @@ export const endpointSpecFormSchema = z.object({
|
|||||||
|
|
||||||
interface EndpointSpecFormProps {
|
interface EndpointSpecFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
||||||
@@ -51,7 +44,6 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -64,7 +56,6 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -103,7 +94,6 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
endpointSpecSwarm: hasAnyValue ? formData : null,
|
endpointSpecSwarm: hasAnyValue ? formData : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -26,14 +26,7 @@ export const healthCheckFormSchema = z.object({
|
|||||||
|
|
||||||
interface HealthCheckFormProps {
|
interface HealthCheckFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||||
@@ -49,7 +42,6 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -62,7 +54,6 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -113,7 +104,6 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
healthCheckSwarm: hasAnyValue ? formData : null,
|
healthCheckSwarm: hasAnyValue ? formData : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,14 +29,7 @@ export const labelsFormSchema = z.object({
|
|||||||
|
|
||||||
interface LabelsFormProps {
|
interface LabelsFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
||||||
@@ -52,7 +45,6 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -65,7 +57,6 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -121,7 +112,6 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
labelsSwarm: labelsToSend,
|
labelsSwarm: labelsToSend,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,14 +23,7 @@ import { api } from "@/utils/api";
|
|||||||
|
|
||||||
interface ModeFormProps {
|
interface ModeFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ModeForm = ({ id, type }: ModeFormProps) => {
|
export const ModeForm = ({ id, type }: ModeFormProps) => {
|
||||||
@@ -46,7 +39,6 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -59,7 +51,6 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -104,7 +95,6 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
modeSwarm: null,
|
modeSwarm: null,
|
||||||
});
|
});
|
||||||
toast.success("Mode updated successfully");
|
toast.success("Mode updated successfully");
|
||||||
@@ -132,7 +122,6 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
modeSwarm: modeData,
|
modeSwarm: modeData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -35,14 +35,7 @@ export const networkFormSchema = z.object({
|
|||||||
|
|
||||||
interface NetworkFormProps {
|
interface NetworkFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
||||||
@@ -58,7 +51,6 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -71,7 +63,6 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -141,7 +132,6 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
networkSwarm: networksToSend,
|
networkSwarm: networksToSend,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -34,14 +34,7 @@ export const placementFormSchema = z.object({
|
|||||||
|
|
||||||
interface PlacementFormProps {
|
interface PlacementFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
||||||
@@ -57,7 +50,6 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -70,7 +62,6 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -123,7 +114,6 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
placementSwarm: hasAnyValue
|
placementSwarm: hasAnyValue
|
||||||
? {
|
? {
|
||||||
...formData,
|
...formData,
|
||||||
|
|||||||
@@ -32,14 +32,7 @@ export const restartPolicyFormSchema = z.object({
|
|||||||
|
|
||||||
interface RestartPolicyFormProps {
|
interface RestartPolicyFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
||||||
@@ -55,7 +48,6 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -68,7 +60,6 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -113,7 +104,6 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
restartPolicySwarm: hasAnyValue ? formData : null,
|
restartPolicySwarm: hasAnyValue ? formData : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -34,14 +34,7 @@ export const rollbackConfigFormSchema = z.object({
|
|||||||
|
|
||||||
interface RollbackConfigFormProps {
|
interface RollbackConfigFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
||||||
@@ -57,7 +50,6 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -70,7 +62,6 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -112,7 +103,6 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
rollbackConfigSwarm: (hasAnyValue ? formData : null) as any,
|
rollbackConfigSwarm: (hasAnyValue ? formData : null) as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,21 +16,14 @@ import { api } from "@/utils/api";
|
|||||||
|
|
||||||
const hasStopGracePeriodSwarm = (
|
const hasStopGracePeriodSwarm = (
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): value is { stopGracePeriodSwarm: number | string | null } =>
|
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
|
||||||
typeof value === "object" &&
|
typeof value === "object" &&
|
||||||
value !== null &&
|
value !== null &&
|
||||||
"stopGracePeriodSwarm" in value;
|
"stopGracePeriodSwarm" in value;
|
||||||
|
|
||||||
interface StopGracePeriodFormProps {
|
interface StopGracePeriodFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||||
@@ -46,7 +39,6 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -59,7 +51,6 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -68,7 +59,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
|||||||
|
|
||||||
const form = useForm<any>({
|
const form = useForm<any>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
value: null as number | null,
|
value: null as bigint | null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,7 +67,11 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
|||||||
if (hasStopGracePeriodSwarm(data)) {
|
if (hasStopGracePeriodSwarm(data)) {
|
||||||
const value = data.stopGracePeriodSwarm;
|
const value = data.stopGracePeriodSwarm;
|
||||||
const normalizedValue =
|
const normalizedValue =
|
||||||
value === null || value === undefined ? null : Number(value);
|
value === null || value === undefined
|
||||||
|
? null
|
||||||
|
: typeof value === "bigint"
|
||||||
|
? value
|
||||||
|
: BigInt(value);
|
||||||
form.reset({
|
form.reset({
|
||||||
value: normalizedValue,
|
value: normalizedValue,
|
||||||
});
|
});
|
||||||
@@ -93,7 +88,6 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
stopGracePeriodSwarm: formData.value,
|
stopGracePeriodSwarm: formData.value,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,7 +126,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
|||||||
}
|
}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
field.onChange(
|
field.onChange(
|
||||||
e.target.value ? Number(e.target.value) : null,
|
e.target.value ? BigInt(e.target.value) : null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -34,14 +34,7 @@ export const updateConfigFormSchema = z.object({
|
|||||||
|
|
||||||
interface UpdateConfigFormProps {
|
interface UpdateConfigFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
||||||
@@ -57,7 +50,6 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -70,7 +62,6 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -118,7 +109,6 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
updateConfigSwarm: (hasAnyValue ? formData : null) as any,
|
updateConfigSwarm: (hasAnyValue ? formData : null) as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -37,13 +37,13 @@ import { Separator } from "@/components/ui/separator";
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const AddRedirectSchema = z.object({
|
const AddRedirectchema = z.object({
|
||||||
regex: z.string().min(1, "Regex required"),
|
regex: z.string().min(1, "Regex required"),
|
||||||
permanent: z.boolean().default(false),
|
permanent: z.boolean().default(false),
|
||||||
replacement: z.string().min(1, "Replacement required"),
|
replacement: z.string().min(1, "Replacement required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
type AddRedirect = z.infer<typeof AddRedirectSchema>;
|
type AddRedirect = z.infer<typeof AddRedirectchema>;
|
||||||
|
|
||||||
// Default presets
|
// Default presets
|
||||||
const redirectPresets = [
|
const redirectPresets = [
|
||||||
@@ -110,7 +110,7 @@ export const HandleRedirect = ({
|
|||||||
regex: "",
|
regex: "",
|
||||||
replacement: "",
|
replacement: "",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(AddRedirectSchema),
|
resolver: zodResolver(AddRedirectchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -149,7 +149,7 @@ export const HandleRedirect = ({
|
|||||||
|
|
||||||
const onDialogToggle = (open: boolean) => {
|
const onDialogToggle = (open: boolean) => {
|
||||||
setIsOpen(open);
|
setIsOpen(open);
|
||||||
// commented for the moment because not resetting the form if accidentally closed the dialog can be considered as a feature instead of a bug
|
// commented for the moment because not reseting the form if accidentally closed the dialog can be considered as a feature instead of a bug
|
||||||
// setPresetSelected("");
|
// setPresetSelected("");
|
||||||
// form.reset();
|
// form.reset();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -89,13 +89,12 @@ const ULIMIT_PRESETS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export type ServiceType =
|
export type ServiceType =
|
||||||
| "application"
|
|
||||||
| "libsql"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "postgres"
|
| "postgres"
|
||||||
| "redis";
|
| "mongo"
|
||||||
|
| "redis"
|
||||||
|
| "mysql"
|
||||||
|
| "mariadb"
|
||||||
|
| "application";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -106,29 +105,27 @@ type AddResources = z.infer<typeof addResourcesSchema>;
|
|||||||
|
|
||||||
export const ShowResources = ({ id, type }: Props) => {
|
export const ShowResources = ({ id, type }: Props) => {
|
||||||
const queryMap = {
|
const queryMap = {
|
||||||
application: () =>
|
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
mariadb: () =>
|
|
||||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
|
||||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
|
||||||
postgres: () =>
|
postgres: () =>
|
||||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
const mutationMap = {
|
const mutationMap = {
|
||||||
application: () => api.application.update.useMutation(),
|
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
mariadb: () => api.mariadb.update.useMutation(),
|
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
|
||||||
mysql: () => api.mysql.update.useMutation(),
|
|
||||||
postgres: () => api.postgres.update.useMutation(),
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
redis: () => api.redis.update.useMutation(),
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
application: () => api.application.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync, isPending } = mutationMap[type]
|
const { mutateAsync, isPending } = mutationMap[type]
|
||||||
@@ -158,20 +155,19 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
cpuReservation: data?.cpuReservation || undefined,
|
cpuReservation: data?.cpuReservation || undefined,
|
||||||
memoryLimit: data?.memoryLimit || undefined,
|
memoryLimit: data?.memoryLimit || undefined,
|
||||||
memoryReservation: data?.memoryReservation || undefined,
|
memoryReservation: data?.memoryReservation || undefined,
|
||||||
ulimitsSwarm: (data as any)?.ulimitsSwarm || [],
|
ulimitsSwarm: data?.ulimitsSwarm || [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, form, form.reset]);
|
}, [data, form, form.reset]);
|
||||||
|
|
||||||
const onSubmit = async (formData: AddResources) => {
|
const onSubmit = async (formData: AddResources) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
applicationId: id || "",
|
|
||||||
libsqlId: id || "",
|
|
||||||
mariadbId: id || "",
|
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
mysqlId: id || "",
|
|
||||||
postgresId: id || "",
|
postgresId: id || "",
|
||||||
redisId: id || "",
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
applicationId: id || "",
|
||||||
cpuLimit: formData.cpuLimit || null,
|
cpuLimit: formData.cpuLimit || null,
|
||||||
cpuReservation: formData.cpuReservation || null,
|
cpuReservation: formData.cpuReservation || null,
|
||||||
memoryLimit: formData.memoryLimit || null,
|
memoryLimit: formData.memoryLimit || null,
|
||||||
|
|||||||
@@ -34,13 +34,13 @@ interface Props {
|
|||||||
serviceId: string;
|
serviceId: string;
|
||||||
serviceType:
|
serviceType:
|
||||||
| "application"
|
| "application"
|
||||||
| "compose"
|
|
||||||
| "libsql"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "postgres"
|
| "postgres"
|
||||||
| "redis";
|
| "redis"
|
||||||
|
| "mongo"
|
||||||
|
| "redis"
|
||||||
|
| "mysql"
|
||||||
|
| "mariadb"
|
||||||
|
| "compose";
|
||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,25 +29,23 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
|||||||
if (!canRead) return null;
|
if (!canRead) return null;
|
||||||
|
|
||||||
const queryMap = {
|
const queryMap = {
|
||||||
application: () =>
|
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
|
||||||
compose: () =>
|
|
||||||
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
mariadb: () =>
|
|
||||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
|
||||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
|
||||||
postgres: () =>
|
postgres: () =>
|
||||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
compose: () =>
|
||||||
|
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
const { mutateAsync: deleteVolume, isPending: isRemoving } =
|
const { mutateAsync: deleteVolume, isPending: isRemoving } =
|
||||||
api.mounts.remove.useMutation();
|
api.mounts.remove.useMutation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
|
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
|
||||||
|
|||||||
@@ -67,13 +67,13 @@ interface Props {
|
|||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
serviceType:
|
serviceType:
|
||||||
| "application"
|
| "application"
|
||||||
| "compose"
|
|
||||||
| "libsql"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "postgres"
|
| "postgres"
|
||||||
| "redis";
|
| "redis"
|
||||||
|
| "mongo"
|
||||||
|
| "redis"
|
||||||
|
| "mysql"
|
||||||
|
| "mariadb"
|
||||||
|
| "compose";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UpdateVolume = ({
|
export const UpdateVolume = ({
|
||||||
@@ -253,7 +253,7 @@ export const UpdateVolume = ({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="content"
|
name="content"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="w-full max-w-[45rem]">
|
<FormItem className="max-w-full max-w-[45rem]">
|
||||||
<FormLabel>Content</FormLabel>
|
<FormLabel>Content</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import copy from "copy-to-clipboard";
|
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
@@ -12,6 +11,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
|||||||
@@ -1,303 +0,0 @@
|
|||||||
import type { ColumnDef } from "@tanstack/react-table";
|
|
||||||
import {
|
|
||||||
ArrowUpDown,
|
|
||||||
CheckCircle2,
|
|
||||||
ExternalLink,
|
|
||||||
Loader2,
|
|
||||||
PenBoxIcon,
|
|
||||||
RefreshCw,
|
|
||||||
Server,
|
|
||||||
Trash2,
|
|
||||||
XCircle,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import type { RouterOutputs } from "@/utils/api";
|
|
||||||
import type { ValidationStates } from "./show-domains";
|
|
||||||
import { AddDomain } from "./handle-domain";
|
|
||||||
import { DnsHelperModal } from "./dns-helper-modal";
|
|
||||||
|
|
||||||
export type Domain =
|
|
||||||
| RouterOutputs["domain"]["byApplicationId"][0]
|
|
||||||
| RouterOutputs["domain"]["byComposeId"][0];
|
|
||||||
|
|
||||||
interface ColumnsProps {
|
|
||||||
id: string;
|
|
||||||
type: "application" | "compose";
|
|
||||||
validationStates: ValidationStates;
|
|
||||||
handleValidateDomain: (host: string) => Promise<void>;
|
|
||||||
handleDeleteDomain: (domainId: string) => Promise<void>;
|
|
||||||
isDeleting: boolean;
|
|
||||||
serverIp?: string;
|
|
||||||
canCreateDomain: boolean;
|
|
||||||
canDeleteDomain: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createColumns = ({
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
validationStates,
|
|
||||||
handleValidateDomain,
|
|
||||||
handleDeleteDomain,
|
|
||||||
isDeleting,
|
|
||||||
serverIp,
|
|
||||||
canCreateDomain,
|
|
||||||
canDeleteDomain,
|
|
||||||
}: ColumnsProps): ColumnDef<Domain>[] => [
|
|
||||||
...(type === "compose"
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
accessorKey: "serviceName",
|
|
||||||
header: "Service",
|
|
||||||
cell: ({ row }: { row: { getValue: (key: string) => unknown } }) => {
|
|
||||||
const serviceName = row.getValue("serviceName") as string | null;
|
|
||||||
if (!serviceName) return null;
|
|
||||||
return (
|
|
||||||
<Badge variant="outline">
|
|
||||||
<Server className="size-3 mr-1" />
|
|
||||||
{serviceName}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
} satisfies ColumnDef<Domain>,
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
|
||||||
accessorKey: "host",
|
|
||||||
header: ({ column }) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Host
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const domain = row.original;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
className="flex items-center gap-2 font-medium hover:underline"
|
|
||||||
target="_blank"
|
|
||||||
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
|
||||||
>
|
|
||||||
{domain.host}
|
|
||||||
<ExternalLink className="size-3" />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "path",
|
|
||||||
header: ({ column }) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Path
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const path = row.getValue("path") as string;
|
|
||||||
return <div className="font-mono text-sm">{path || "/"}</div>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "port",
|
|
||||||
header: ({ column }) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Port
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const port = row.getValue("port") as number;
|
|
||||||
return <Badge variant="secondary">{port}</Badge>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "customEntrypoint",
|
|
||||||
header: "Entrypoint",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const entrypoint = row.getValue("customEntrypoint") as string | null;
|
|
||||||
if (!entrypoint) return <span className="text-muted-foreground">-</span>;
|
|
||||||
return <div className="font-mono text-sm">{entrypoint}</div>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "https",
|
|
||||||
header: "Protocol",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const https = row.getValue("https") as boolean;
|
|
||||||
return (
|
|
||||||
<Badge variant={https ? "outline" : "secondary"}>
|
|
||||||
{https ? "HTTPS" : "HTTP"}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "certificate",
|
|
||||||
header: "Certificate",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const domain = row.original;
|
|
||||||
const validationState = validationStates[domain.host];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{domain.certificateType && (
|
|
||||||
<Badge variant="outline" className="capitalize">
|
|
||||||
{domain.certificateType}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{!domain.host.includes("traefik.me") && (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={
|
|
||||||
validationState?.isValid
|
|
||||||
? "bg-green-500/10 text-green-500 cursor-pointer"
|
|
||||||
: validationState?.error
|
|
||||||
? "bg-red-500/10 text-red-500 cursor-pointer"
|
|
||||||
: "bg-yellow-500/10 text-yellow-500 cursor-pointer"
|
|
||||||
}
|
|
||||||
onClick={() => handleValidateDomain(domain.host)}
|
|
||||||
>
|
|
||||||
{validationState?.isLoading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="size-3 mr-1 animate-spin" />
|
|
||||||
Checking...
|
|
||||||
</>
|
|
||||||
) : validationState?.isValid ? (
|
|
||||||
<>
|
|
||||||
<CheckCircle2 className="size-3 mr-1" />
|
|
||||||
{validationState.message && validationState.cdnProvider
|
|
||||||
? `${validationState.cdnProvider}`
|
|
||||||
: "Valid"}
|
|
||||||
</>
|
|
||||||
) : validationState?.error ? (
|
|
||||||
<>
|
|
||||||
<XCircle className="size-3 mr-1" />
|
|
||||||
Invalid
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="size-3 mr-1" />
|
|
||||||
Validate
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="max-w-xs">
|
|
||||||
{validationState?.error ? (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<p className="font-medium text-red-500">Error:</p>
|
|
||||||
<p>{validationState.error}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
"Click to validate DNS configuration"
|
|
||||||
)}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "createdAt",
|
|
||||||
header: ({ column }) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Created
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const createdAt = row.getValue("createdAt") as string;
|
|
||||||
return (
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{new Date(createdAt).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
header: "Actions",
|
|
||||||
enableHiding: false,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const domain = row.original;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{!domain.host.includes("traefik.me") && (
|
|
||||||
<DnsHelperModal
|
|
||||||
domain={{
|
|
||||||
host: domain.host,
|
|
||||||
https: domain.https,
|
|
||||||
path: domain.path || undefined,
|
|
||||||
}}
|
|
||||||
serverIp={serverIp}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{canCreateDomain && (
|
|
||||||
<AddDomain id={id} type={type} domainId={domain.domainId}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-blue-500/10 h-8 w-8"
|
|
||||||
>
|
|
||||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
|
||||||
</Button>
|
|
||||||
</AddDomain>
|
|
||||||
)}
|
|
||||||
{canDeleteDomain && (
|
|
||||||
<DialogAction
|
|
||||||
title="Delete Domain"
|
|
||||||
description="Are you sure you want to delete this domain?"
|
|
||||||
type="destructive"
|
|
||||||
onClick={async () => {
|
|
||||||
await handleDeleteDomain(domain.domainId);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-red-500/10 h-8 w-8"
|
|
||||||
isLoading={isDeleting}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { DatabaseZap, Dices, RefreshCw, X } from "lucide-react";
|
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -62,14 +61,11 @@ export const domain = z
|
|||||||
.min(1, { message: "Port must be at least 1" })
|
.min(1, { message: "Port must be at least 1" })
|
||||||
.max(65535, { message: "Port must be 65535 or below" })
|
.max(65535, { message: "Port must be 65535 or below" })
|
||||||
.optional(),
|
.optional(),
|
||||||
useCustomEntrypoint: z.boolean(),
|
|
||||||
customEntrypoint: z.string().optional(),
|
|
||||||
https: z.boolean().optional(),
|
https: z.boolean().optional(),
|
||||||
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
||||||
customCertResolver: z.string().optional(),
|
customCertResolver: z.string().optional(),
|
||||||
serviceName: z.string().optional(),
|
serviceName: z.string().optional(),
|
||||||
domainType: z.enum(["application", "compose", "preview"]).optional(),
|
domainType: z.enum(["application", "compose", "preview"]).optional(),
|
||||||
middlewares: z.array(z.string()).optional(),
|
|
||||||
})
|
})
|
||||||
.superRefine((input, ctx) => {
|
.superRefine((input, ctx) => {
|
||||||
if (input.https && !input.certificateType) {
|
if (input.https && !input.certificateType) {
|
||||||
@@ -118,14 +114,6 @@ export const domain = z
|
|||||||
message: "Internal path must start with '/'",
|
message: "Internal path must start with '/'",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.useCustomEntrypoint && !input.customEntrypoint) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: ["customEntrypoint"],
|
|
||||||
message: "Custom entry point must be specified",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type Domain = z.infer<typeof domain>;
|
type Domain = z.infer<typeof domain>;
|
||||||
@@ -208,20 +196,16 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
internalPath: undefined,
|
internalPath: undefined,
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
port: undefined,
|
port: undefined,
|
||||||
useCustomEntrypoint: false,
|
|
||||||
customEntrypoint: undefined,
|
|
||||||
https: false,
|
https: false,
|
||||||
certificateType: undefined,
|
certificateType: undefined,
|
||||||
customCertResolver: undefined,
|
customCertResolver: undefined,
|
||||||
serviceName: undefined,
|
serviceName: undefined,
|
||||||
domainType: type,
|
domainType: type,
|
||||||
middlewares: [],
|
|
||||||
},
|
},
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
const certificateType = form.watch("certificateType");
|
const certificateType = form.watch("certificateType");
|
||||||
const useCustomEntrypoint = form.watch("useCustomEntrypoint");
|
|
||||||
const https = form.watch("https");
|
const https = form.watch("https");
|
||||||
const domainType = form.watch("domainType");
|
const domainType = form.watch("domainType");
|
||||||
const host = form.watch("host");
|
const host = form.watch("host");
|
||||||
@@ -236,13 +220,10 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
internalPath: data?.internalPath || undefined,
|
internalPath: data?.internalPath || undefined,
|
||||||
stripPath: data?.stripPath || false,
|
stripPath: data?.stripPath || false,
|
||||||
port: data?.port || undefined,
|
port: data?.port || undefined,
|
||||||
useCustomEntrypoint: !!data.customEntrypoint,
|
|
||||||
customEntrypoint: data.customEntrypoint || undefined,
|
|
||||||
certificateType: data?.certificateType || undefined,
|
certificateType: data?.certificateType || undefined,
|
||||||
customCertResolver: data?.customCertResolver || undefined,
|
customCertResolver: data?.customCertResolver || undefined,
|
||||||
serviceName: data?.serviceName || undefined,
|
serviceName: data?.serviceName || undefined,
|
||||||
domainType: data?.domainType || type,
|
domainType: data?.domainType || type,
|
||||||
middlewares: data?.middlewares || [],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,13 +234,10 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
internalPath: undefined,
|
internalPath: undefined,
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
port: undefined,
|
port: undefined,
|
||||||
useCustomEntrypoint: false,
|
|
||||||
customEntrypoint: undefined,
|
|
||||||
https: false,
|
https: false,
|
||||||
certificateType: undefined,
|
certificateType: undefined,
|
||||||
customCertResolver: undefined,
|
customCertResolver: undefined,
|
||||||
domainType: type,
|
domainType: type,
|
||||||
middlewares: [],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, data, isPending, domainId]);
|
}, [form, data, isPending, domainId]);
|
||||||
@@ -290,7 +268,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
composeId: id,
|
composeId: id,
|
||||||
}),
|
}),
|
||||||
...data,
|
...data,
|
||||||
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
|
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success(dictionary.success);
|
toast.success(dictionary.success);
|
||||||
@@ -658,55 +635,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="useCustomEntrypoint"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel>Custom Entrypoint</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Use custom entrypoint for domina
|
|
||||||
<br />
|
|
||||||
"web" and/or "websecure" is used by default.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
field.onChange(checked);
|
|
||||||
if (!checked) {
|
|
||||||
form.setValue("customEntrypoint", undefined);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{useCustomEntrypoint && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="customEntrypoint"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="w-full">
|
|
||||||
<FormLabel>Entrypoint Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="Enter entrypoint name manually"
|
|
||||||
{...field}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="https"
|
name="https"
|
||||||
@@ -797,88 +725,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="middlewares"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FormLabel>Middlewares</FormLabel>
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
|
||||||
?
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="max-w-[300px]">
|
|
||||||
<p>
|
|
||||||
Add Traefik middleware references. Middlewares
|
|
||||||
must be defined in your Traefik configuration.
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
|
||||||
{field.value?.map((name, index) => (
|
|
||||||
<Badge key={index} variant="secondary">
|
|
||||||
{name}
|
|
||||||
<X
|
|
||||||
className="ml-1 size-3 cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
const newMiddlewares = [...(field.value || [])];
|
|
||||||
newMiddlewares.splice(index, 1);
|
|
||||||
form.setValue("middlewares", newMiddlewares);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
placeholder="e.g., rate-limit@file, auth@file"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
const input = e.currentTarget;
|
|
||||||
const value = input.value.trim();
|
|
||||||
if (value && !field.value?.includes(value)) {
|
|
||||||
form.setValue("middlewares", [
|
|
||||||
...(field.value || []),
|
|
||||||
value,
|
|
||||||
]);
|
|
||||||
input.value = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
const input = document.querySelector(
|
|
||||||
'input[placeholder="e.g., rate-limit@file, auth@file"]',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const value = input.value.trim();
|
|
||||||
if (value && !field.value?.includes(value)) {
|
|
||||||
form.setValue("middlewares", [
|
|
||||||
...(field.value || []),
|
|
||||||
value,
|
|
||||||
]);
|
|
||||||
input.value = "";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,22 +1,8 @@
|
|||||||
import {
|
|
||||||
type ColumnFiltersState,
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
getFilteredRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
type SortingState,
|
|
||||||
useReactTable,
|
|
||||||
type VisibilityState,
|
|
||||||
} from "@tanstack/react-table";
|
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronDown,
|
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
LayoutGrid,
|
|
||||||
LayoutList,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
PenBoxIcon,
|
PenBoxIcon,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -37,21 +23,6 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -59,7 +30,6 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { createColumns } from "./columns";
|
|
||||||
import { DnsHelperModal } from "./dns-helper-modal";
|
import { DnsHelperModal } from "./dns-helper-modal";
|
||||||
import { AddDomain } from "./handle-domain";
|
import { AddDomain } from "./handle-domain";
|
||||||
|
|
||||||
@@ -104,19 +74,6 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
const [validationStates, setValidationStates] = useState<ValidationStates>(
|
const [validationStates, setValidationStates] = useState<ValidationStates>(
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "table">(() => {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
return (
|
|
||||||
(localStorage.getItem("domains-view-mode") as "grid" | "table") ??
|
|
||||||
"grid"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return "grid";
|
|
||||||
});
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
|
||||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
|
||||||
const [rowSelection, setRowSelection] = useState({});
|
|
||||||
const { data: ip } = api.settings.getIp.useQuery();
|
const { data: ip } = api.settings.getIp.useQuery();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -146,16 +103,6 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
const { mutateAsync: deleteDomain, isPending: isRemoving } =
|
const { mutateAsync: deleteDomain, isPending: isRemoving } =
|
||||||
api.domain.delete.useMutation();
|
api.domain.delete.useMutation();
|
||||||
|
|
||||||
const handleDeleteDomain = async (domainId: string) => {
|
|
||||||
try {
|
|
||||||
await deleteDomain({ domainId });
|
|
||||||
refetch();
|
|
||||||
toast.success("Domain deleted successfully");
|
|
||||||
} catch {
|
|
||||||
toast.error("Error deleting domain");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleValidateDomain = async (host: string) => {
|
const handleValidateDomain = async (host: string) => {
|
||||||
setValidationStates((prev) => ({
|
setValidationStates((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -193,37 +140,6 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = createColumns({
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
validationStates,
|
|
||||||
handleValidateDomain,
|
|
||||||
handleDeleteDomain,
|
|
||||||
isDeleting: isRemoving,
|
|
||||||
serverIp: application?.server?.ipAddress?.toString() || ip?.toString(),
|
|
||||||
canCreateDomain,
|
|
||||||
canDeleteDomain,
|
|
||||||
});
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: data ?? [],
|
|
||||||
columns,
|
|
||||||
onSortingChange: setSorting,
|
|
||||||
onColumnFiltersChange: setColumnFilters,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
|
||||||
onRowSelectionChange: setRowSelection,
|
|
||||||
state: {
|
|
||||||
sorting,
|
|
||||||
columnFilters,
|
|
||||||
columnVisibility,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
@@ -235,32 +151,13 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row gap-2 flex-wrap">
|
<div className="flex flex-row gap-4 flex-wrap">
|
||||||
{data && data?.length > 0 && (
|
{canCreateDomain && data && data?.length > 0 && (
|
||||||
<>
|
<AddDomain id={id} type={type}>
|
||||||
<Button
|
<Button>
|
||||||
variant="outline"
|
<GlobeIcon className="size-4" /> Add Domain
|
||||||
size="icon"
|
|
||||||
onClick={() => {
|
|
||||||
const next = viewMode === "grid" ? "table" : "grid";
|
|
||||||
localStorage.setItem("domains-view-mode", next);
|
|
||||||
setViewMode(next);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{viewMode === "grid" ? (
|
|
||||||
<LayoutList className="size-4" />
|
|
||||||
) : (
|
|
||||||
<LayoutGrid className="size-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
{canCreateDomain && (
|
</AddDomain>
|
||||||
<AddDomain id={id} type={type}>
|
|
||||||
<Button>
|
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
|
||||||
</Button>
|
|
||||||
</AddDomain>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -289,122 +186,6 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === "table" ? (
|
|
||||||
<div className="flex flex-col gap-4 w-full">
|
|
||||||
<div className="flex items-center gap-2 max-sm:flex-wrap">
|
|
||||||
<Input
|
|
||||||
placeholder="Filter by host..."
|
|
||||||
value={
|
|
||||||
(table.getColumn("host")?.getFilterValue() as string) ?? ""
|
|
||||||
}
|
|
||||||
onChange={(event) =>
|
|
||||||
table.getColumn("host")?.setFilterValue(event.target.value)
|
|
||||||
}
|
|
||||||
className="md:max-w-sm"
|
|
||||||
/>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="sm:ml-auto max-sm:w-full"
|
|
||||||
>
|
|
||||||
Columns <ChevronDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
{table
|
|
||||||
.getAllColumns()
|
|
||||||
.filter((column) => column.getCanHide())
|
|
||||||
.map((column) => {
|
|
||||||
return (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={column.id}
|
|
||||||
className="capitalize"
|
|
||||||
checked={column.getIsVisible()}
|
|
||||||
onCheckedChange={(value) =>
|
|
||||||
column.toggleVisibility(!!value)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{column.id}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md border">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<TableRow key={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header) => {
|
|
||||||
return (
|
|
||||||
<TableHead key={header.id}>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext(),
|
|
||||||
)}
|
|
||||||
</TableHead>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{table?.getRowModel()?.rows?.length ? (
|
|
||||||
table.getRowModel().rows.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
data-state={row.getIsSelected() && "selected"}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<TableCell key={cell.id}>
|
|
||||||
{flexRender(
|
|
||||||
cell.column.columnDef.cell,
|
|
||||||
cell.getContext(),
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={columns.length}
|
|
||||||
className="h-24 text-center"
|
|
||||||
>
|
|
||||||
No results.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
{data && data?.length > 0 && (
|
|
||||||
<div className="flex items-center justify-end space-x-2 py-4">
|
|
||||||
<div className="space-x-2 flex flex-wrap">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => table.previousPage()}
|
|
||||||
disabled={!table.getCanPreviousPage()}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => table.nextPage()}
|
|
||||||
disabled={!table.getCanNextPage()}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
|
||||||
{data?.map((item) => {
|
{data?.map((item) => {
|
||||||
@@ -560,22 +341,6 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{item.middlewares?.map((middleware, index) => (
|
|
||||||
<TooltipProvider key={`${middleware}-${index}`}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
<InfoIcon className="size-3 mr-1" />
|
|
||||||
Middleware: {middleware}
|
|
||||||
</Badge>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Traefik middleware reference</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -39,16 +39,15 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
const canWrite = permissions?.envVars.write ?? false;
|
const canWrite = permissions?.envVars.write ?? false;
|
||||||
const queryMap = {
|
const queryMap = {
|
||||||
compose: () =>
|
|
||||||
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
mariadb: () =>
|
|
||||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
|
||||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
|
||||||
postgres: () =>
|
postgres: () =>
|
||||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
compose: () =>
|
||||||
|
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -56,13 +55,12 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
||||||
|
|
||||||
const mutationMap = {
|
const mutationMap = {
|
||||||
compose: () => api.compose.update.useMutation(),
|
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
mariadb: () => api.mariadb.update.useMutation(),
|
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
|
||||||
mysql: () => api.mysql.update.useMutation(),
|
|
||||||
postgres: () => api.postgres.update.useMutation(),
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
redis: () => api.redis.update.useMutation(),
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
|
compose: () => api.compose.update.useMutation(),
|
||||||
};
|
};
|
||||||
const { mutateAsync, isPending } = mutationMap[type]
|
const { mutateAsync, isPending } = mutationMap[type]
|
||||||
? mutationMap[type]()
|
? mutationMap[type]()
|
||||||
@@ -89,13 +87,12 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
|
|
||||||
const onSubmit = async (formData: EnvironmentSchema) => {
|
const onSubmit = async (formData: EnvironmentSchema) => {
|
||||||
mutateAsync({
|
mutateAsync({
|
||||||
composeId: id || "",
|
|
||||||
libsqlId: id || "",
|
|
||||||
mariadbId: id || "",
|
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
mysqlId: id || "",
|
|
||||||
postgresId: id || "",
|
postgresId: id || "",
|
||||||
redisId: id || "",
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
composeId: id || "",
|
||||||
env: formData.environment,
|
env: formData.environment,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -116,7 +113,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
|
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
|
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,277 +0,0 @@
|
|||||||
import DOMPurify from "dompurify";
|
|
||||||
import { GlobeIcon, Pencil, Search, X } from "lucide-react";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Dropzone } from "@/components/ui/dropzone";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
interface ShowIconSettingsProps {
|
|
||||||
applicationId: string;
|
|
||||||
icon?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const svgToDataUrl = (icon: BundledIcon): string => {
|
|
||||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#${icon.hex}"><path d="${icon.path}"/></svg>`;
|
|
||||||
return `data:image/svg+xml;base64,${btoa(svg)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ShowIconSettings = ({
|
|
||||||
applicationId,
|
|
||||||
icon,
|
|
||||||
}: ShowIconSettingsProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [iconSearchQuery, setIconSearchQuery] = useState("");
|
|
||||||
const [iconsToShow, setIconsToShow] = useState(24);
|
|
||||||
|
|
||||||
const filteredIcons = useMemo(() => {
|
|
||||||
if (!iconSearchQuery) return bundledIcons;
|
|
||||||
const q = iconSearchQuery.toLowerCase();
|
|
||||||
return bundledIcons.filter(
|
|
||||||
(i) =>
|
|
||||||
i.title.toLowerCase().includes(q) || i.slug.toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
}, [iconSearchQuery]);
|
|
||||||
|
|
||||||
const displayedIcons = filteredIcons.slice(0, iconsToShow);
|
|
||||||
const hasMoreIcons = filteredIcons.length > iconsToShow;
|
|
||||||
|
|
||||||
const utils = api.useUtils();
|
|
||||||
const { mutateAsync: updateApplication } =
|
|
||||||
api.application.update.useMutation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setIconSearchQuery("");
|
|
||||||
setIconsToShow(24);
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const handleIconSelect = async (selectedIcon: BundledIcon) => {
|
|
||||||
try {
|
|
||||||
const dataUrl = svgToDataUrl(selectedIcon);
|
|
||||||
await updateApplication({
|
|
||||||
applicationId,
|
|
||||||
icon: dataUrl,
|
|
||||||
});
|
|
||||||
toast.success("Icon saved successfully");
|
|
||||||
await utils.application.one.invalidate({ applicationId });
|
|
||||||
setOpen(false);
|
|
||||||
} catch (_error) {
|
|
||||||
toast.error("Error saving icon");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveIcon = async () => {
|
|
||||||
try {
|
|
||||||
await updateApplication({
|
|
||||||
applicationId,
|
|
||||||
icon: null,
|
|
||||||
});
|
|
||||||
toast.success("Icon removed");
|
|
||||||
await utils.application.one.invalidate({ applicationId });
|
|
||||||
} catch (_error) {
|
|
||||||
toast.error("Error removing icon");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const sanitizeSvg = (svgContent: string): string | null => {
|
|
||||||
const clean = DOMPurify.sanitize(svgContent, {
|
|
||||||
USE_PROFILES: { svg: true, svgFilters: true },
|
|
||||||
ADD_TAGS: ["use"],
|
|
||||||
});
|
|
||||||
if (!clean) return null;
|
|
||||||
return `data:image/svg+xml;base64,${btoa(clean)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileUpload = async (files: FileList | null) => {
|
|
||||||
if (!files || files.length === 0) return;
|
|
||||||
const file = files[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
const allowedTypes = [
|
|
||||||
"image/jpeg",
|
|
||||||
"image/jpg",
|
|
||||||
"image/png",
|
|
||||||
"image/svg+xml",
|
|
||||||
];
|
|
||||||
const fileExtension = file.name.split(".").pop()?.toLowerCase();
|
|
||||||
const allowedExtensions = ["jpg", "jpeg", "png", "svg"];
|
|
||||||
|
|
||||||
if (
|
|
||||||
!allowedTypes.includes(file.type) &&
|
|
||||||
!allowedExtensions.includes(fileExtension || "")
|
|
||||||
) {
|
|
||||||
toast.error("Only JPG, JPEG, PNG, and SVG files are allowed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > 2 * 1024 * 1024) {
|
|
||||||
toast.error("Image size must be less than 2MB");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSvg = file.type === "image/svg+xml" || fileExtension === "svg";
|
|
||||||
|
|
||||||
if (isSvg) {
|
|
||||||
const text = await file.text();
|
|
||||||
const sanitizedDataUrl = sanitizeSvg(text);
|
|
||||||
if (!sanitizedDataUrl) {
|
|
||||||
toast.error("Invalid SVG file");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await updateApplication({
|
|
||||||
applicationId,
|
|
||||||
icon: sanitizedDataUrl,
|
|
||||||
});
|
|
||||||
toast.success("Icon saved!");
|
|
||||||
await utils.application.one.invalidate({ applicationId });
|
|
||||||
setOpen(false);
|
|
||||||
} catch (_error) {
|
|
||||||
toast.error("Error saving icon");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = async (event) => {
|
|
||||||
const result = event.target?.result as string;
|
|
||||||
try {
|
|
||||||
await updateApplication({
|
|
||||||
applicationId,
|
|
||||||
icon: result,
|
|
||||||
});
|
|
||||||
toast.success("Icon saved!");
|
|
||||||
await utils.application.one.invalidate({ applicationId });
|
|
||||||
setOpen(false);
|
|
||||||
} catch (_error) {
|
|
||||||
toast.error("Error saving icon");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="relative group flex items-center justify-center"
|
|
||||||
>
|
|
||||||
{icon ? (
|
|
||||||
// biome-ignore lint/performance/noImgElement: icon is data URL or base64
|
|
||||||
<img
|
|
||||||
src={icon}
|
|
||||||
alt="Application icon"
|
|
||||||
className="h-8 w-8 object-contain"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<GlobeIcon className="h-6 w-6 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<Pencil className="h-3 w-3 text-white" />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center justify-between">
|
|
||||||
Change Icon
|
|
||||||
{icon && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRemoveIcon}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
|
||||||
<X className="size-4 mr-1" />
|
|
||||||
Remove icon
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search icons (e.g. react, vue, docker)..."
|
|
||||||
value={iconSearchQuery}
|
|
||||||
onChange={(e) => setIconSearchQuery(e.target.value)}
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-h-[300px] overflow-y-auto border rounded-lg p-4">
|
|
||||||
{displayedIcons.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
|
||||||
No icons found
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
|
|
||||||
{displayedIcons.map((i) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={i.slug}
|
|
||||||
onClick={() => handleIconSelect(i)}
|
|
||||||
className="flex flex-col items-center gap-1.5 p-2 rounded-lg border hover:border-primary hover:bg-muted transition-colors group"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
className="size-7 group-hover:scale-110 transition-transform"
|
|
||||||
fill={`#${i.hex}`}
|
|
||||||
>
|
|
||||||
<path d={i.path} />
|
|
||||||
</svg>
|
|
||||||
<span className="text-[10px] text-muted-foreground capitalize truncate w-full text-center">
|
|
||||||
{i.title}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{hasMoreIcons && (
|
|
||||||
<div className="flex justify-center mt-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIconsToShow((prev) => prev + 24)}
|
|
||||||
>
|
|
||||||
Load More ({filteredIcons.length - iconsToShow} remaining)
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative pt-3 border-t">
|
|
||||||
<p className="text-sm text-muted-foreground text-center mb-3">
|
|
||||||
or upload a custom icon
|
|
||||||
</p>
|
|
||||||
<Dropzone
|
|
||||||
dropMessage="Drag & drop an icon or click to upload"
|
|
||||||
accept=".jpg,.jpeg,.png,.svg,image/jpeg,image/png,image/svg+xml"
|
|
||||||
onChange={handleFileUpload}
|
|
||||||
classNameWrapper="border-2 border-dashed border-border hover:border-primary bg-muted/30 hover:bg-muted/50 transition-all rounded-lg"
|
|
||||||
/>
|
|
||||||
<div className="mt-2 text-center text-xs text-muted-foreground">
|
|
||||||
Supported formats: JPG, JPEG, PNG, SVG (max 2MB)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -91,7 +91,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
|||||||
}, [option, services, containers]);
|
}, [option, services, containers]);
|
||||||
|
|
||||||
const isLoading = option === "native" ? containersLoading : servicesLoading;
|
const isLoading = option === "native" ? containersLoading : servicesLoading;
|
||||||
const containersLength =
|
const containersLenght =
|
||||||
option === "native" ? containers?.length : services?.length;
|
option === "native" ? containers?.length : services?.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -167,7 +167,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SelectLabel>Containers ({containersLength})</SelectLabel>
|
<SelectLabel>Containers ({containersLenght})</SelectLabel>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from "./patch-editor";
|
|
||||||
export * from "./show-patches";
|
export * from "./show-patches";
|
||||||
|
export * from "./patch-editor";
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ const formSchema = z
|
|||||||
"mongo",
|
"mongo",
|
||||||
"mysql",
|
"mysql",
|
||||||
"redis",
|
"redis",
|
||||||
"libsql",
|
|
||||||
]),
|
]),
|
||||||
serviceName: z.string(),
|
serviceName: z.string(),
|
||||||
destinationId: z.string().min(1, "Destination required"),
|
destinationId: z.string().min(1, "Destination required"),
|
||||||
@@ -483,7 +482,7 @@ export const HandleVolumeBackups = ({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Choose the volume to backup. If you do not see the
|
Choose the volume to backup, if you dont see the
|
||||||
volume here, you can type the volume name manually
|
volume here, you can type the volume name manually
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -518,7 +517,7 @@ export const HandleVolumeBackups = ({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Choose the volume to backup. If you do not see the volume
|
Choose the volume to backup, if you dont see the volume
|
||||||
here, you can type the volume name manually
|
here, you can type the volume name manually
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ export const DeleteService = ({ id, type }: Props) => {
|
|||||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
mariadb: () =>
|
mariadb: () =>
|
||||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
@@ -73,7 +72,6 @@ export const DeleteService = ({ id, type }: Props) => {
|
|||||||
redis: () => api.redis.remove.useMutation(),
|
redis: () => api.redis.remove.useMutation(),
|
||||||
mysql: () => api.mysql.remove.useMutation(),
|
mysql: () => api.mysql.remove.useMutation(),
|
||||||
mariadb: () => api.mariadb.remove.useMutation(),
|
mariadb: () => api.mariadb.remove.useMutation(),
|
||||||
libsql: () => api.libsql.remove.useMutation(),
|
|
||||||
application: () => api.application.delete.useMutation(),
|
application: () => api.application.delete.useMutation(),
|
||||||
mongo: () => api.mongo.remove.useMutation(),
|
mongo: () => api.mongo.remove.useMutation(),
|
||||||
compose: () => api.compose.delete.useMutation(),
|
compose: () => api.compose.delete.useMutation(),
|
||||||
@@ -100,7 +98,6 @@ export const DeleteService = ({ id, type }: Props) => {
|
|||||||
redisId: id || "",
|
redisId: id || "",
|
||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
applicationId: id || "",
|
applicationId: id || "",
|
||||||
composeId: id || "",
|
composeId: id || "",
|
||||||
deleteVolumes,
|
deleteVolumes,
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
|||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
|
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, Plus, X, HelpCircle } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, Plus, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -409,8 +409,10 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger>
|
||||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||||
|
?
|
||||||
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
|||||||
}, [option, services, containers]);
|
}, [option, services, containers]);
|
||||||
|
|
||||||
const isLoading = option === "native" ? containersLoading : servicesLoading;
|
const isLoading = option === "native" ? containersLoading : servicesLoading;
|
||||||
const containersLength =
|
const containersLenght =
|
||||||
option === "native" ? containers?.length : services?.length;
|
option === "native" ? containers?.length : services?.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -152,7 +152,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SelectLabel>Containers ({containersLength})</SelectLabel>
|
<SelectLabel>Containers ({containersLenght})</SelectLabel>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -65,13 +65,7 @@ import { ScheduleFormField } from "../../application/schedules/handle-schedules"
|
|||||||
|
|
||||||
type CacheType = "cache" | "fetch";
|
type CacheType = "cache" | "fetch";
|
||||||
|
|
||||||
type DatabaseType =
|
type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mysql"
|
|
||||||
| "mongo"
|
|
||||||
| "web-server"
|
|
||||||
| "libsql";
|
|
||||||
|
|
||||||
const Schema = z
|
const Schema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -83,7 +77,7 @@ const Schema = z
|
|||||||
keepLatestCount: z.coerce.number().optional(),
|
keepLatestCount: z.coerce.number().optional(),
|
||||||
serviceName: z.string().nullable(),
|
serviceName: z.string().nullable(),
|
||||||
databaseType: z
|
databaseType: z
|
||||||
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server", "libsql"])
|
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
|
||||||
.optional(),
|
.optional(),
|
||||||
backupType: z.enum(["database", "compose"]),
|
backupType: z.enum(["database", "compose"]),
|
||||||
metadata: z
|
metadata: z
|
||||||
@@ -215,12 +209,7 @@ export const HandleBackup = ({
|
|||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
database:
|
database: databaseType === "web-server" ? "dokploy" : "",
|
||||||
databaseType === "web-server"
|
|
||||||
? "dokploy"
|
|
||||||
: databaseType === "libsql"
|
|
||||||
? "iku.db"
|
|
||||||
: "",
|
|
||||||
destinationId: "",
|
destinationId: "",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
prefix: "/",
|
prefix: "/",
|
||||||
@@ -257,9 +246,7 @@ export const HandleBackup = ({
|
|||||||
? backup?.database
|
? backup?.database
|
||||||
: databaseType === "web-server"
|
: databaseType === "web-server"
|
||||||
? "dokploy"
|
? "dokploy"
|
||||||
: databaseType === "libsql"
|
: "",
|
||||||
? "iku.db"
|
|
||||||
: "",
|
|
||||||
destinationId: backup?.destinationId ?? "",
|
destinationId: backup?.destinationId ?? "",
|
||||||
enabled: backup?.enabled ?? true,
|
enabled: backup?.enabled ?? true,
|
||||||
prefix: backup?.prefix ?? "/",
|
prefix: backup?.prefix ?? "/",
|
||||||
@@ -294,15 +281,11 @@ export const HandleBackup = ({
|
|||||||
? {
|
? {
|
||||||
mongoId: id,
|
mongoId: id,
|
||||||
}
|
}
|
||||||
: databaseType === "libsql"
|
: databaseType === "web-server"
|
||||||
? {
|
? {
|
||||||
libsqlId: id,
|
userId: id,
|
||||||
}
|
}
|
||||||
: databaseType === "web-server"
|
: undefined;
|
||||||
? {
|
|
||||||
userId: id,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
await createBackup({
|
await createBackup({
|
||||||
destinationId: data.destinationId,
|
destinationId: data.destinationId,
|
||||||
@@ -585,10 +568,7 @@ export const HandleBackup = ({
|
|||||||
<FormLabel>Database</FormLabel>
|
<FormLabel>Database</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
disabled={
|
disabled={databaseType === "web-server"}
|
||||||
databaseType === "web-server" ||
|
|
||||||
databaseType === "libsql"
|
|
||||||
}
|
|
||||||
placeholder={"dokploy"}
|
placeholder={"dokploy"}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ const RestoreBackupSchema = z
|
|||||||
message: "Database name is required",
|
message: "Database name is required",
|
||||||
}),
|
}),
|
||||||
databaseType: z
|
databaseType: z
|
||||||
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server", "libsql"])
|
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
|
||||||
.optional(),
|
.optional(),
|
||||||
backupType: z.enum(["database", "compose"]).default("database"),
|
backupType: z.enum(["database", "compose"]).default("database"),
|
||||||
metadata: z
|
metadata: z
|
||||||
@@ -211,12 +211,7 @@ export const RestoreBackup = ({
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
destinationId: "",
|
destinationId: "",
|
||||||
backupFile: "",
|
backupFile: "",
|
||||||
databaseName:
|
databaseName: databaseType === "web-server" ? "dokploy" : "",
|
||||||
databaseType === "web-server"
|
|
||||||
? "dokploy"
|
|
||||||
: databaseType === "libsql"
|
|
||||||
? "iku.db"
|
|
||||||
: "",
|
|
||||||
databaseType:
|
databaseType:
|
||||||
backupType === "compose" ? ("postgres" as DatabaseType) : databaseType,
|
backupType === "compose" ? ("postgres" as DatabaseType) : databaseType,
|
||||||
backupType: backupType,
|
backupType: backupType,
|
||||||
@@ -225,7 +220,7 @@ export const RestoreBackup = ({
|
|||||||
resolver: zodResolver(RestoreBackupSchema),
|
resolver: zodResolver(RestoreBackupSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const destinationId = form.watch("destinationId");
|
const destionationId = form.watch("destinationId");
|
||||||
const currentDatabaseType = form.watch("databaseType");
|
const currentDatabaseType = form.watch("databaseType");
|
||||||
const metadata = form.watch("metadata");
|
const metadata = form.watch("metadata");
|
||||||
|
|
||||||
@@ -240,12 +235,12 @@ export const RestoreBackup = ({
|
|||||||
|
|
||||||
const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery(
|
const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery(
|
||||||
{
|
{
|
||||||
destinationId: destinationId,
|
destinationId: destionationId,
|
||||||
search: debouncedSearchTerm,
|
search: debouncedSearchTerm,
|
||||||
serverId: serverId ?? "",
|
serverId: serverId ?? "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: isOpen && !!destinationId,
|
enabled: isOpen && !!destionationId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -528,10 +523,7 @@ export const RestoreBackup = ({
|
|||||||
<Input
|
<Input
|
||||||
placeholder="Enter database name"
|
placeholder="Enter database name"
|
||||||
{...field}
|
{...field}
|
||||||
disabled={
|
disabled={databaseType === "web-server"}
|
||||||
databaseType === "web-server" ||
|
|
||||||
databaseType === "libsql"
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@@ -53,16 +53,14 @@ export const ShowBackups = ({
|
|||||||
const queryMap =
|
const queryMap =
|
||||||
backupType === "database"
|
backupType === "database"
|
||||||
? {
|
? {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
|
mysql: () =>
|
||||||
|
api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
mariadb: () =>
|
mariadb: () =>
|
||||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
mongo: () =>
|
mongo: () =>
|
||||||
api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
mysql: () =>
|
|
||||||
api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
|
||||||
postgres: () =>
|
|
||||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
|
||||||
libsql: () =>
|
|
||||||
api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
"web-server": () => api.user.getBackups.useQuery(),
|
"web-server": () => api.user.getBackups.useQuery(),
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
@@ -79,11 +77,10 @@ export const ShowBackups = ({
|
|||||||
const mutationMap =
|
const mutationMap =
|
||||||
backupType === "database"
|
backupType === "database"
|
||||||
? {
|
? {
|
||||||
|
postgres: api.backup.manualBackupPostgres.useMutation(),
|
||||||
|
mysql: api.backup.manualBackupMySql.useMutation(),
|
||||||
mariadb: api.backup.manualBackupMariadb.useMutation(),
|
mariadb: api.backup.manualBackupMariadb.useMutation(),
|
||||||
mongo: api.backup.manualBackupMongo.useMutation(),
|
mongo: api.backup.manualBackupMongo.useMutation(),
|
||||||
mysql: api.backup.manualBackupMySql.useMutation(),
|
|
||||||
postgres: api.backup.manualBackupPostgres.useMutation(),
|
|
||||||
libsql: api.backup.manualBackupLibsql.useMutation(),
|
|
||||||
"web-server": api.backup.manualBackupWebServer.useMutation(),
|
"web-server": api.backup.manualBackupWebServer.useMutation(),
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { inferRouterOutputs } from "@trpc/server";
|
import type { inferRouterOutputs } from "@trpc/server";
|
||||||
import { ArrowRight, ListTodo, Loader2, XCircle } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { ArrowRight, ListTodo, Loader2, XCircle } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
|
|||||||
>
|
>
|
||||||
{" "}
|
{" "}
|
||||||
<div className="flex items-start gap-x-2">
|
<div className="flex items-start gap-x-2">
|
||||||
{/* Icon to expand the log item maybe implement a collapsible later */}
|
{/* Icon to expand the log item maybe implement a colapsible later */}
|
||||||
{/* <Square className="size-4 text-muted-foreground opacity-0 group-hover/logitem:opacity-100 transition-opacity" /> */}
|
{/* <Square className="size-4 text-muted-foreground opacity-0 group-hover/logitem:opacity-100 transition-opacity" /> */}
|
||||||
{tooltip(color, rawTimestamp)}
|
{tooltip(color, rawTimestamp)}
|
||||||
{!noTimestamp && (
|
{!noTimestamp && (
|
||||||
|
|||||||
@@ -74,18 +74,6 @@ export function parseLogs(logString: string): LogLine[] {
|
|||||||
|
|
||||||
// Detect log type based on message content
|
// Detect log type based on message content
|
||||||
export const getLogType = (message: string): LogStyle => {
|
export const getLogType = (message: string): LogStyle => {
|
||||||
// Detect HTTP statusCode
|
|
||||||
const statusMatch = message.match(/"statusCode"\s*:\s*"?(\d{3})"?/);
|
|
||||||
|
|
||||||
if (statusMatch) {
|
|
||||||
const statusCode = Number(statusMatch[1]);
|
|
||||||
|
|
||||||
if (statusCode >= 500) return LOG_STYLES.error;
|
|
||||||
if (statusCode >= 400) return LOG_STYLES.warning;
|
|
||||||
if (statusCode >= 200 && statusCode < 300) return LOG_STYLES.success;
|
|
||||||
return LOG_STYLES.info;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lowerMessage = message.toLowerCase();
|
const lowerMessage = message.toLowerCase();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import { toast } from "sonner";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
containerId: string;
|
|
||||||
serverId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RemoveContainerDialog = ({ containerId, serverId }: Props) => {
|
|
||||||
const utils = api.useUtils();
|
|
||||||
const { mutateAsync, isPending } = api.docker.removeContainer.useMutation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
Remove Container
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This will permanently remove the container{" "}
|
|
||||||
<span className="font-semibold">{containerId}</span>. If the
|
|
||||||
container is running, it will be forcefully stopped and removed.
|
|
||||||
This action cannot be undone.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
disabled={isPending}
|
|
||||||
onClick={async () => {
|
|
||||||
await mutateAsync({ containerId, serverId })
|
|
||||||
.then(async () => {
|
|
||||||
toast.success("Container removed successfully");
|
|
||||||
await utils.docker.getContainers.invalidate();
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
toast.error(err.message);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -10,9 +10,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { ShowContainerConfig } from "../config/show-container-config";
|
import { ShowContainerConfig } from "../config/show-container-config";
|
||||||
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
|
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
|
||||||
import { RemoveContainerDialog } from "../remove/remove-container";
|
|
||||||
import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
|
import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
|
||||||
import { UploadFileModal } from "../upload/upload-file-modal";
|
|
||||||
import type { Container } from "./show-containers";
|
import type { Container } from "./show-containers";
|
||||||
|
|
||||||
export const columns: ColumnDef<Container>[] = [
|
export const columns: ColumnDef<Container>[] = [
|
||||||
@@ -129,16 +127,6 @@ export const columns: ColumnDef<Container>[] = [
|
|||||||
>
|
>
|
||||||
Terminal
|
Terminal
|
||||||
</DockerTerminalModal>
|
</DockerTerminalModal>
|
||||||
<UploadFileModal
|
|
||||||
containerId={container.containerId}
|
|
||||||
serverId={container.serverId || undefined}
|
|
||||||
>
|
|
||||||
Upload File
|
|
||||||
</UploadFileModal>
|
|
||||||
<RemoveContainerDialog
|
|
||||||
containerId={container.containerId}
|
|
||||||
serverId={container.serverId ?? undefined}
|
|
||||||
/>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { api, type RouterOutputs } from "@/utils/api";
|
import { api, type RouterOutputs } from "@/utils/api";
|
||||||
import { columns } from "./columns";
|
import { columns } from "./colums";
|
||||||
export type Container = NonNullable<
|
export type Container = NonNullable<
|
||||||
RouterOutputs["docker"]["getContainers"]
|
RouterOutputs["docker"]["getContainers"]
|
||||||
>[0];
|
>[0];
|
||||||
|
|||||||
@@ -1,187 +0,0 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
|
||||||
import { Upload } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
|
||||||
import { Dropzone } from "@/components/ui/dropzone";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import {
|
|
||||||
uploadFileToContainerSchema,
|
|
||||||
type UploadFileToContainer,
|
|
||||||
} from "@/utils/schema";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
containerId: string;
|
|
||||||
serverId?: string;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UploadFileModal = ({ children, containerId, serverId }: Props) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { mutateAsync: uploadFile, isPending: isLoading } =
|
|
||||||
api.docker.uploadFileToContainer.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("File uploaded successfully");
|
|
||||||
setOpen(false);
|
|
||||||
form.reset();
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error.message || "Failed to upload file to container");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(uploadFileToContainerSchema),
|
|
||||||
defaultValues: {
|
|
||||||
containerId,
|
|
||||||
destinationPath: "/",
|
|
||||||
serverId: serverId || undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const file = form.watch("file");
|
|
||||||
|
|
||||||
const onSubmit = async (values: UploadFileToContainer) => {
|
|
||||||
if (!values.file) {
|
|
||||||
toast.error("Please select a file to upload");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("containerId", values.containerId);
|
|
||||||
formData.append("file", values.file);
|
|
||||||
formData.append("destinationPath", values.destinationPath);
|
|
||||||
if (values.serverId) {
|
|
||||||
formData.append("serverId", values.serverId);
|
|
||||||
}
|
|
||||||
|
|
||||||
await uploadFile(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="w-full cursor-pointer space-x-3"
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<Upload className="h-5 w-5" />
|
|
||||||
Upload File to Container
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Upload a file directly into the container's filesystem
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="destinationPath"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Destination Path</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
placeholder="/path/to/file"
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Enter the full path where the file should be uploaded in the
|
|
||||||
container (e.g., /app/config.json)
|
|
||||||
</p>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="file"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>File</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Dropzone
|
|
||||||
{...field}
|
|
||||||
dropMessage="Drop file here or click to browse"
|
|
||||||
onChange={(files) => {
|
|
||||||
if (files && files.length > 0) {
|
|
||||||
field.onChange(files[0]);
|
|
||||||
} else {
|
|
||||||
field.onChange(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
{file instanceof File && (
|
|
||||||
<div className="flex items-center gap-2 p-2 bg-muted rounded-md">
|
|
||||||
<span className="text-sm text-muted-foreground flex-1">
|
|
||||||
{file.name} ({(file.size / 1024).toFixed(2)} KB)
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => field.onChange(null)}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
isLoading={isLoading}
|
|
||||||
disabled={!file || isLoading}
|
|
||||||
>
|
|
||||||
Upload File
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
|
||||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
const DockerProviderSchema = z.object({
|
|
||||||
externalPort: z.preprocess((a) => {
|
|
||||||
if (a === null || a === undefined || a === "") return null;
|
|
||||||
const parsed = Number.parseInt(String(a), 10);
|
|
||||||
return Number.isNaN(parsed) ? null : parsed;
|
|
||||||
}, z
|
|
||||||
.number()
|
|
||||||
.gte(0, "Range must be 0 - 65535")
|
|
||||||
.lte(65535, "Range must be 0 - 65535")
|
|
||||||
.nullable()),
|
|
||||||
externalGRPCPort: z.preprocess((a) => {
|
|
||||||
if (a === null || a === undefined || a === "") return null;
|
|
||||||
const parsed = Number.parseInt(String(a), 10);
|
|
||||||
return Number.isNaN(parsed) ? null : parsed;
|
|
||||||
}, z
|
|
||||||
.number()
|
|
||||||
.gte(0, "Range must be 0 - 65535")
|
|
||||||
.lte(65535, "Range must be 0 - 65535")
|
|
||||||
.nullable()),
|
|
||||||
externalAdminPort: z.preprocess((a) => {
|
|
||||||
if (a === null || a === undefined || a === "") return null;
|
|
||||||
const parsed = Number.parseInt(String(a), 10);
|
|
||||||
return Number.isNaN(parsed) ? null : parsed;
|
|
||||||
}, z
|
|
||||||
.number()
|
|
||||||
.gte(0, "Range must be 0 - 65535")
|
|
||||||
.lte(65535, "Range must be 0 - 65535")
|
|
||||||
.nullable()),
|
|
||||||
});
|
|
||||||
|
|
||||||
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
libsqlId: string;
|
|
||||||
}
|
|
||||||
export const ShowExternalLibsqlCredentials = ({ libsqlId }: Props) => {
|
|
||||||
const { data: ip } = api.settings.getIp.useQuery();
|
|
||||||
const { data, refetch } = api.libsql.one.useQuery({ libsqlId });
|
|
||||||
const { mutateAsync, isPending } = api.libsql.saveExternalPorts.useMutation();
|
|
||||||
const [connectionUrl, setConnectionUrl] = useState("");
|
|
||||||
const [connectionGRPCUrl, setGRPCConnectionUrl] = useState("");
|
|
||||||
const getIp = data?.server?.ipAddress || ip;
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
defaultValues: {},
|
|
||||||
resolver: zodResolver(DockerProviderSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data) {
|
|
||||||
form.reset({
|
|
||||||
externalPort: data.externalPort,
|
|
||||||
externalGRPCPort: data.externalGRPCPort,
|
|
||||||
externalAdminPort: data.externalAdminPort,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [form.reset, data, form]);
|
|
||||||
|
|
||||||
const onSubmit = async (values: DockerProvider) => {
|
|
||||||
await mutateAsync({
|
|
||||||
externalPort: values.externalPort,
|
|
||||||
externalGRPCPort: values.externalGRPCPort,
|
|
||||||
externalAdminPort: values.externalAdminPort,
|
|
||||||
libsqlId,
|
|
||||||
})
|
|
||||||
.then(async () => {
|
|
||||||
toast.success("External port/ports updated");
|
|
||||||
await refetch();
|
|
||||||
})
|
|
||||||
.catch((error: Error) => {
|
|
||||||
toast.error(error?.message || "Error saving the external port/ports");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const port = form.watch("externalPort") || data?.externalPort;
|
|
||||||
setConnectionUrl(
|
|
||||||
`http://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (data?.sqldNode !== "replica") {
|
|
||||||
const grpcPort = form.watch("externalGRPCPort") || data?.externalGRPCPort;
|
|
||||||
setGRPCConnectionUrl(
|
|
||||||
`http://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${grpcPort}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
data?.externalGRPCPort,
|
|
||||||
data?.databasePassword,
|
|
||||||
form,
|
|
||||||
data?.databaseUser,
|
|
||||||
getIp,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex w-full flex-col gap-5">
|
|
||||||
<Card className="bg-background">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
In order to make the database reachable through the internet, you
|
|
||||||
must set a port and ensure that the port is not being used by
|
|
||||||
another application or database
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex w-full flex-col gap-4">
|
|
||||||
{!getIp && (
|
|
||||||
<AlertBlock type="warning">
|
|
||||||
You need to set an IP address in your{" "}
|
|
||||||
<Link href="/dashboard/settings/server" className="text-primary">
|
|
||||||
{data?.serverId
|
|
||||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
|
||||||
: "Web Server -> Server -> Update Server IP"}
|
|
||||||
</Link>{" "}
|
|
||||||
to fix the database url connection.
|
|
||||||
</AlertBlock>
|
|
||||||
)}
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
className="flex flex-col gap-4"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="col-span-2 space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="externalPort"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>External Port (Internet)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="8080"
|
|
||||||
{...field}
|
|
||||||
value={field.value as string}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!!data?.externalPort && (
|
|
||||||
<div className="grid w-full gap-8">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<Label>External Host</Label>
|
|
||||||
<ToggleVisibilityInput value={connectionUrl} disabled />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="col-span-2 space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="externalAdminPort"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>External Admin Port (Internet)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="5000"
|
|
||||||
{...field}
|
|
||||||
value={field.value as string}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{data?.sqldNode !== "replica" && (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="col-span-2 space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="externalGRPCPort"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>External GRPC Port (Internet)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="5001"
|
|
||||||
{...field}
|
|
||||||
value={field.value as string}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!!data?.externalGRPCPort && (
|
|
||||||
<div className="grid w-full gap-8">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<Label>External GRPC Host</Label>
|
|
||||||
<ToggleVisibilityInput
|
|
||||||
value={connectionGRPCUrl}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button type="submit" isLoading={isPending}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
|
||||||
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
|
||||||
import { DrawerLogs } from "@/components/shared/drawer-logs";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { type LogLine, parseLogs } from "../../docker/logs/utils";
|
|
||||||
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
libsqlId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ShowGeneralLibsql = ({ libsqlId }: Props) => {
|
|
||||||
const { data, refetch } = api.libsql.one.useQuery(
|
|
||||||
{
|
|
||||||
libsqlId,
|
|
||||||
},
|
|
||||||
{ enabled: !!libsqlId },
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutateAsync: reload, isPending: isReloading } =
|
|
||||||
api.libsql.reload.useMutation();
|
|
||||||
|
|
||||||
const { mutateAsync: start, isPending: isStarting } =
|
|
||||||
api.libsql.start.useMutation();
|
|
||||||
|
|
||||||
const { mutateAsync: stop, isPending: isStopping } =
|
|
||||||
api.libsql.stop.useMutation();
|
|
||||||
|
|
||||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
|
||||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
|
||||||
const [isDeploying, setIsDeploying] = useState(false);
|
|
||||||
api.libsql.deployWithLogs.useSubscription(
|
|
||||||
{
|
|
||||||
libsqlId: libsqlId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: isDeploying,
|
|
||||||
onData(log) {
|
|
||||||
if (!isDrawerOpen) {
|
|
||||||
setIsDrawerOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (log === "Deployment completed successfully!") {
|
|
||||||
setIsDeploying(false);
|
|
||||||
}
|
|
||||||
const parsedLogs = parseLogs(log);
|
|
||||||
setFilteredLogs((prev) => [...prev, ...parsedLogs]);
|
|
||||||
},
|
|
||||||
onError(error) {
|
|
||||||
console.error("Deployment logs error:", error);
|
|
||||||
setIsDeploying(false);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
|
||||||
<Card className="bg-background">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-xl">Deploy Settings</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<DialogAction
|
|
||||||
title="Deploy Libsql"
|
|
||||||
description="Are you sure you want to deploy this Libsql?"
|
|
||||||
type="default"
|
|
||||||
onClick={async () => {
|
|
||||||
setIsDeploying(true);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
refetch();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
isLoading={data?.applicationStatus === "running"}
|
|
||||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Rocket className="size-4 mr-1" />
|
|
||||||
Deploy
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipPrimitive.Portal>
|
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
|
||||||
<p>Downloads and sets up the Libsql database</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</TooltipPrimitive.Portal>
|
|
||||||
</Tooltip>
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
</TooltipProvider>
|
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<DialogAction
|
|
||||||
title="Reload Libsql"
|
|
||||||
description="Are you sure you want to reload this libsql?"
|
|
||||||
type="default"
|
|
||||||
onClick={async () => {
|
|
||||||
await reload({
|
|
||||||
libsqlId: libsqlId,
|
|
||||||
appName: data?.appName || "",
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success("Libsql reloaded successfully");
|
|
||||||
refetch();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error reloading Libsql");
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
isLoading={isReloading}
|
|
||||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<RefreshCcw className="size-4 mr-1" />
|
|
||||||
Reload
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipPrimitive.Portal>
|
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
|
||||||
<p>Restart the Libsql service without rebuilding</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</TooltipPrimitive.Portal>
|
|
||||||
</Tooltip>
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
</TooltipProvider>
|
|
||||||
{data?.applicationStatus === "idle" ? (
|
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<DialogAction
|
|
||||||
title="Start Libsql"
|
|
||||||
description="Are you sure you want to start this Libsql?"
|
|
||||||
type="default"
|
|
||||||
onClick={async () => {
|
|
||||||
await start({
|
|
||||||
libsqlId: libsqlId,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success("Libsql started successfully");
|
|
||||||
refetch();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error starting Libsql");
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
isLoading={isStarting}
|
|
||||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<CheckCircle2 className="size-4 mr-1" />
|
|
||||||
Start
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipPrimitive.Portal>
|
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
|
||||||
<p>
|
|
||||||
Start the Libsql database (requires a previous
|
|
||||||
successful setup)
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</TooltipPrimitive.Portal>
|
|
||||||
</Tooltip>
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
</TooltipProvider>
|
|
||||||
) : (
|
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<DialogAction
|
|
||||||
title="Stop Libsql"
|
|
||||||
description="Are you sure you want to stop this Libsql?"
|
|
||||||
onClick={async () => {
|
|
||||||
await stop({
|
|
||||||
libsqlId: libsqlId,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success("Libsql stopped successfully");
|
|
||||||
refetch();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error stopping Libsql");
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
isLoading={isStopping}
|
|
||||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Ban className="size-4 mr-1" />
|
|
||||||
Stop
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipPrimitive.Portal>
|
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
|
||||||
<p>Stop the currently running Libsql database</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</TooltipPrimitive.Portal>
|
|
||||||
</Tooltip>
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
<DockerTerminalModal
|
|
||||||
appName={data?.appName || ""}
|
|
||||||
serverId={data?.serverId || ""}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Terminal className="size-4 mr-1" />
|
|
||||||
Open Terminal
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipPrimitive.Portal>
|
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
|
||||||
<p>Open a terminal to the Libsql container</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</TooltipPrimitive.Portal>
|
|
||||||
</Tooltip>
|
|
||||||
</Button>
|
|
||||||
</DockerTerminalModal>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<DrawerLogs
|
|
||||||
isOpen={isDrawerOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setIsDrawerOpen(false);
|
|
||||||
setFilteredLogs([]);
|
|
||||||
setIsDeploying(false);
|
|
||||||
refetch();
|
|
||||||
}}
|
|
||||||
filteredLogs={filteredLogs}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import { SelectGroup } from "@radix-ui/react-select";
|
|
||||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
libsqlId: string;
|
|
||||||
}
|
|
||||||
export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => {
|
|
||||||
const { data } = api.libsql.one.useQuery({ libsqlId });
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
|
||||||
<Card className="bg-background">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-xl">Internal Credentials</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex w-full flex-row gap-4">
|
|
||||||
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label>User</Label>
|
|
||||||
<Input disabled value={data?.databaseUser} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label>Sqld Node</Label>
|
|
||||||
<Select value={data?.sqldNode} disabled>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select Node type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{["primary", "replica"].map((node) => (
|
|
||||||
<SelectItem key={node} value={node}>
|
|
||||||
{node.charAt(0).toUpperCase() + node.slice(1)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label>Password</Label>
|
|
||||||
<div className="flex flex-row gap-4">
|
|
||||||
<ToggleVisibilityInput
|
|
||||||
disabled
|
|
||||||
value={data?.databasePassword}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-row gap-2">
|
|
||||||
<div className="w-full flex flex-col gap-2">
|
|
||||||
<Label>Internal Port (Container)</Label>
|
|
||||||
<Input disabled value="8080" />
|
|
||||||
</div>
|
|
||||||
<div className="w-full flex flex-col gap-2">
|
|
||||||
<Label>Internal GRPC Port (Container)</Label>
|
|
||||||
<Input disabled value="5001" />
|
|
||||||
</div>
|
|
||||||
<div className="w-full flex flex-col gap-2">
|
|
||||||
<Label>Internal Admin Port (Container)</Label>
|
|
||||||
<Input disabled value="5000" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label>Internal Host</Label>
|
|
||||||
<Input disabled value={data?.appName} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label>Enable Namespaces</Label>
|
|
||||||
<Select
|
|
||||||
disabled
|
|
||||||
defaultValue={
|
|
||||||
data?.enableNamespaces
|
|
||||||
? String(data?.enableNamespaces)
|
|
||||||
: "false"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={"false"} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{["false", "true"].map((node) => (
|
|
||||||
<SelectItem key={node} value={node}>
|
|
||||||
{node.charAt(0).toUpperCase() + node.slice(1)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 md:col-span-2">
|
|
||||||
<Label>Internal Connection URL </Label>
|
|
||||||
<ToggleVisibilityInput
|
|
||||||
disabled
|
|
||||||
value={`http://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:8080`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 md:col-span-2">
|
|
||||||
<Label>Internal Replication Connection URL </Label>
|
|
||||||
<ToggleVisibilityInput
|
|
||||||
disabled
|
|
||||||
value={`http://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:5001`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
|
||||||
import { PenBoxIcon } from "lucide-react";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
const updateLibsqlSchema = z.object({
|
|
||||||
name: z.string().min(1, {
|
|
||||||
message: "Name is required",
|
|
||||||
}),
|
|
||||||
description: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type UpdateLibsql = z.infer<typeof updateLibsqlSchema>;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
libsqlId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UpdateLibsql = ({ libsqlId }: Props) => {
|
|
||||||
const utils = api.useUtils();
|
|
||||||
const { mutateAsync, error, isError, isPending } =
|
|
||||||
api.libsql.update.useMutation();
|
|
||||||
const { data } = api.libsql.one.useQuery(
|
|
||||||
{
|
|
||||||
libsqlId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!libsqlId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const form = useForm<UpdateLibsql>({
|
|
||||||
defaultValues: {
|
|
||||||
description: data?.description ?? "",
|
|
||||||
name: data?.name ?? "",
|
|
||||||
},
|
|
||||||
resolver: zodResolver(updateLibsqlSchema),
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
|
||||||
if (data) {
|
|
||||||
form.reset({
|
|
||||||
description: data.description ?? "",
|
|
||||||
name: data.name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [data, form, form.reset]);
|
|
||||||
|
|
||||||
const onSubmit = async (formData: UpdateLibsql) => {
|
|
||||||
await mutateAsync({
|
|
||||||
name: formData.name,
|
|
||||||
libsqlId: libsqlId,
|
|
||||||
description: formData.description || "",
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success("Libsql updated successfully");
|
|
||||||
utils.libsql.one.invalidate({
|
|
||||||
libsqlId: libsqlId,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error updating the Libsql");
|
|
||||||
})
|
|
||||||
.finally(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-blue-500/10 "
|
|
||||||
>
|
|
||||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Modify Libsql</DialogTitle>
|
|
||||||
<DialogDescription>Update the Libsql data</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
|
||||||
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<div className="grid items-center gap-4">
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
id="hook-form-update-libsql"
|
|
||||||
className="grid w-full gap-4 "
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Vandelay Industries" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="description"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Description</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
placeholder="Description about your project..."
|
|
||||||
className="resize-none"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
isLoading={isPending}
|
|
||||||
form="hook-form-update-libsql"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Update
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,19 +1,14 @@
|
|||||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||||
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mariadbId: string;
|
mariadbId: string;
|
||||||
}
|
}
|
||||||
export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
|
export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
|
||||||
const { data } = api.mariadb.one.useQuery({ mariadbId });
|
const { data } = api.mariadb.one.useQuery({ mariadbId });
|
||||||
const utils = api.useUtils();
|
|
||||||
const { mutateAsync: changePassword } =
|
|
||||||
api.mariadb.changePassword.useMutation();
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
@@ -33,43 +28,20 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Password</Label>
|
<Label>Password</Label>
|
||||||
<div className="flex flex-row gap-2 items-center">
|
<div className="flex flex-row gap-4">
|
||||||
<ToggleVisibilityInput
|
<ToggleVisibilityInput
|
||||||
disabled
|
disabled
|
||||||
value={data?.databasePassword}
|
value={data?.databasePassword}
|
||||||
/>
|
/>
|
||||||
<UpdateDatabasePassword
|
|
||||||
onUpdatePassword={async (newPassword) => {
|
|
||||||
await changePassword({
|
|
||||||
mariadbId,
|
|
||||||
password: newPassword,
|
|
||||||
type: "user",
|
|
||||||
});
|
|
||||||
toast.success("Password updated successfully");
|
|
||||||
utils.mariadb.one.invalidate({ mariadbId });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Root Password</Label>
|
<Label>Root Password</Label>
|
||||||
<div className="flex flex-row gap-2 items-center">
|
<div className="flex flex-row gap-4">
|
||||||
<ToggleVisibilityInput
|
<ToggleVisibilityInput
|
||||||
disabled
|
disabled
|
||||||
value={data?.databaseRootPassword}
|
value={data?.databaseRootPassword}
|
||||||
/>
|
/>
|
||||||
<UpdateDatabasePassword
|
|
||||||
label="Root Password"
|
|
||||||
onUpdatePassword={async (newPassword) => {
|
|
||||||
await changePassword({
|
|
||||||
mariadbId,
|
|
||||||
password: newPassword,
|
|
||||||
type: "root",
|
|
||||||
});
|
|
||||||
toast.success("Root password updated successfully");
|
|
||||||
utils.mariadb.one.invalidate({ mariadbId });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||||
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mongoId: string;
|
mongoId: string;
|
||||||
}
|
}
|
||||||
export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
|
export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
|
||||||
const { data } = api.mongo.one.useQuery({ mongoId });
|
const { data } = api.mongo.one.useQuery({ mongoId });
|
||||||
const utils = api.useUtils();
|
|
||||||
const { mutateAsync: changePassword } =
|
|
||||||
api.mongo.changePassword.useMutation();
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
@@ -30,21 +25,11 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Password</Label>
|
<Label>Password</Label>
|
||||||
<div className="flex flex-row gap-2 items-center">
|
<div className="flex flex-row gap-4">
|
||||||
<ToggleVisibilityInput
|
<ToggleVisibilityInput
|
||||||
disabled
|
disabled
|
||||||
value={data?.databasePassword}
|
value={data?.databasePassword}
|
||||||
/>
|
/>
|
||||||
<UpdateDatabasePassword
|
|
||||||
onUpdatePassword={async (newPassword) => {
|
|
||||||
await changePassword({
|
|
||||||
mongoId,
|
|
||||||
password: newPassword,
|
|
||||||
});
|
|
||||||
toast.success("Password updated successfully");
|
|
||||||
utils.mongo.one.invalidate({ mongoId });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,103 +1,103 @@
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
|
|
||||||
import {
|
import {
|
||||||
type ChartConfig,
|
Area,
|
||||||
ChartContainer,
|
AreaChart,
|
||||||
ChartLegend,
|
CartesianGrid,
|
||||||
ChartLegendContent,
|
Legend,
|
||||||
ChartTooltip,
|
ResponsiveContainer,
|
||||||
ChartTooltipContent,
|
Tooltip,
|
||||||
} from "@/components/ui/chart";
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
accumulativeData: DockerStatsJSON["block"];
|
acummulativeData: DockerStatsJSON["block"];
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartConfig = {
|
export const DockerBlockChart = ({ acummulativeData }: Props) => {
|
||||||
readMb: {
|
const transformedData = acummulativeData.map((item, index) => {
|
||||||
label: "Read (MB)",
|
return {
|
||||||
color: "hsl(var(--chart-1))",
|
time: item.time,
|
||||||
},
|
name: `Point ${index + 1}`,
|
||||||
writeMb: {
|
readMb: item.value.readMb,
|
||||||
label: "Write (MB)",
|
writeMb: item.value.writeMb,
|
||||||
color: "hsl(var(--chart-2))",
|
};
|
||||||
},
|
});
|
||||||
} satisfies ChartConfig;
|
|
||||||
|
|
||||||
export const DockerBlockChart = ({ accumulativeData }: Props) => {
|
|
||||||
const transformedData = accumulativeData.map((item, index) => ({
|
|
||||||
time: item.time,
|
|
||||||
name: `Point ${index + 1}`,
|
|
||||||
readMb: item.value.readMb,
|
|
||||||
writeMb: item.value.writeMb,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
|
<div className="mt-6 w-full h-[10rem]">
|
||||||
<AreaChart
|
<ResponsiveContainer>
|
||||||
data={transformedData}
|
<AreaChart
|
||||||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
data={transformedData}
|
||||||
>
|
margin={{
|
||||||
<defs>
|
top: 10,
|
||||||
<linearGradient id="fillBlockRead" x1="0" y1="0" x2="0" y2="1">
|
right: 30,
|
||||||
<stop
|
left: 0,
|
||||||
offset="5%"
|
bottom: 0,
|
||||||
stopColor="var(--color-readMb)"
|
}}
|
||||||
stopOpacity={0.8}
|
>
|
||||||
/>
|
<defs>
|
||||||
<stop
|
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
|
||||||
offset="95%"
|
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
|
||||||
stopColor="var(--color-readMb)"
|
<stop offset="95%" stopColor="#8884d8" stopOpacity={0} />
|
||||||
stopOpacity={0.1}
|
</linearGradient>
|
||||||
/>
|
<linearGradient id="colorWrite" x1="0" y1="0" x2="0" y2="1">
|
||||||
</linearGradient>
|
<stop offset="5%" stopColor="#82ca9d" stopOpacity={0.8} />
|
||||||
<linearGradient id="fillBlockWrite" x1="0" y1="0" x2="0" y2="1">
|
<stop offset="95%" stopColor="#82ca9d" stopOpacity={0} />
|
||||||
<stop
|
</linearGradient>
|
||||||
offset="5%"
|
</defs>
|
||||||
stopColor="var(--color-writeMb)"
|
<YAxis stroke="#A1A1AA" />
|
||||||
stopOpacity={0.8}
|
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
|
||||||
/>
|
{/* @ts-ignore */}
|
||||||
<stop
|
<Tooltip content={<CustomTooltip />} />
|
||||||
offset="95%"
|
<Legend />
|
||||||
stopColor="var(--color-writeMb)"
|
<Area
|
||||||
stopOpacity={0.1}
|
type="monotone"
|
||||||
/>
|
dataKey="readMb"
|
||||||
</linearGradient>
|
stroke="#27272A"
|
||||||
</defs>
|
fillOpacity={1}
|
||||||
<CartesianGrid vertical={false} />
|
fill="url(#colorUv)"
|
||||||
<YAxis tickLine={false} axisLine={false} />
|
name="Read Mb"
|
||||||
<ChartTooltip
|
/>
|
||||||
cursor={false}
|
<Area
|
||||||
content={
|
type="monotone"
|
||||||
<ChartTooltipContent
|
dataKey="writeMb"
|
||||||
labelFormatter={(_, payload) => {
|
stroke="#82ca9d"
|
||||||
const time = payload?.[0]?.payload?.time;
|
fillOpacity={1}
|
||||||
return time ? format(new Date(time), "PPpp") : "";
|
fill="url(#colorWrite)"
|
||||||
}}
|
name="Write Mb"
|
||||||
formatter={(value, name) => {
|
/>
|
||||||
const label = name === "readMb" ? "Read" : "Write";
|
</AreaChart>
|
||||||
return [`${value} MB`, label];
|
</ResponsiveContainer>
|
||||||
}}
|
</div>
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="readMb"
|
|
||||||
stroke="var(--color-readMb)"
|
|
||||||
fill="url(#fillBlockRead)"
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="writeMb"
|
|
||||||
stroke="var(--color-writeMb)"
|
|
||||||
fill="url(#fillBlockWrite)"
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
<ChartLegend content={<ChartLegendContent />} />
|
|
||||||
</AreaChart>
|
|
||||||
</ChartContainer>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
interface CustomTooltipProps {
|
||||||
|
active: boolean;
|
||||||
|
payload?: {
|
||||||
|
color?: string;
|
||||||
|
dataKey?: string;
|
||||||
|
value?: number;
|
||||||
|
payload: {
|
||||||
|
time: string;
|
||||||
|
readMb: number;
|
||||||
|
writeMb: number;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
|
||||||
|
if (active && payload && payload.length && payload[0]) {
|
||||||
|
return (
|
||||||
|
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
|
||||||
|
{payload[0].payload.time && (
|
||||||
|
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
|
||||||
|
)}
|
||||||
|
<p>{`Read ${payload[0].payload.readMb} `}</p>
|
||||||
|
<p>{`Write: ${payload[0].payload.writeMb} `}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,81 +1,87 @@
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
|
|
||||||
import {
|
import {
|
||||||
type ChartConfig,
|
Area,
|
||||||
ChartContainer,
|
AreaChart,
|
||||||
ChartLegend,
|
CartesianGrid,
|
||||||
ChartLegendContent,
|
Legend,
|
||||||
ChartTooltip,
|
ResponsiveContainer,
|
||||||
ChartTooltipContent,
|
Tooltip,
|
||||||
} from "@/components/ui/chart";
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
accumulativeData: DockerStatsJSON["cpu"];
|
acummulativeData: DockerStatsJSON["cpu"];
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartConfig = {
|
export const DockerCpuChart = ({ acummulativeData }: Props) => {
|
||||||
usage: {
|
const transformedData = acummulativeData.map((item, index) => {
|
||||||
label: "CPU Usage",
|
return {
|
||||||
color: "hsl(var(--chart-1))",
|
name: `Point ${index + 1}`,
|
||||||
},
|
time: item.time,
|
||||||
} satisfies ChartConfig;
|
usage: item.value.toString().split("%")[0],
|
||||||
|
};
|
||||||
export const DockerCpuChart = ({ accumulativeData }: Props) => {
|
});
|
||||||
const transformedData = accumulativeData.map((item, index) => ({
|
|
||||||
name: `Point ${index + 1}`,
|
|
||||||
time: item.time,
|
|
||||||
usage: item.value.toString().split("%")[0],
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
|
<div className="mt-6 w-full h-[10rem]">
|
||||||
<AreaChart
|
<ResponsiveContainer>
|
||||||
data={transformedData}
|
<AreaChart
|
||||||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
data={transformedData}
|
||||||
>
|
margin={{
|
||||||
<defs>
|
top: 10,
|
||||||
<linearGradient id="fillCpu" x1="0" y1="0" x2="0" y2="1">
|
right: 30,
|
||||||
<stop
|
left: 0,
|
||||||
offset="5%"
|
bottom: 0,
|
||||||
stopColor="var(--color-usage)"
|
}}
|
||||||
stopOpacity={0.8}
|
>
|
||||||
/>
|
<defs>
|
||||||
<stop
|
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
|
||||||
offset="95%"
|
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
|
||||||
stopColor="var(--color-usage)"
|
<stop offset="95%" stopColor="white" stopOpacity={0} />
|
||||||
stopOpacity={0.1}
|
</linearGradient>
|
||||||
/>
|
</defs>
|
||||||
</linearGradient>
|
<YAxis stroke="#A1A1AA" domain={[0, 100]} />
|
||||||
</defs>
|
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
|
||||||
<CartesianGrid vertical={false} />
|
{/* @ts-ignore */}
|
||||||
<YAxis
|
<Tooltip content={<CustomTooltip />} />
|
||||||
tickFormatter={(value) => `${value}%`}
|
<Legend />
|
||||||
domain={[0, 100]}
|
<Area
|
||||||
tickLine={false}
|
type="monotone"
|
||||||
axisLine={false}
|
dataKey="usage"
|
||||||
/>
|
stroke="#27272A"
|
||||||
<ChartTooltip
|
fillOpacity={1}
|
||||||
cursor={false}
|
fill="url(#colorUv)"
|
||||||
content={
|
/>
|
||||||
<ChartTooltipContent
|
</AreaChart>
|
||||||
labelFormatter={(_, payload) => {
|
</ResponsiveContainer>
|
||||||
const time = payload?.[0]?.payload?.time;
|
</div>
|
||||||
return time ? format(new Date(time), "PPpp") : "";
|
|
||||||
}}
|
|
||||||
formatter={(value) => [`${value}%`, "CPU Usage"]}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="usage"
|
|
||||||
stroke="var(--color-usage)"
|
|
||||||
fill="url(#fillCpu)"
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
<ChartLegend content={<ChartLegendContent />} />
|
|
||||||
</AreaChart>
|
|
||||||
</ChartContainer>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface CustomTooltipProps {
|
||||||
|
active: boolean;
|
||||||
|
payload?: {
|
||||||
|
color?: string;
|
||||||
|
dataKey?: string;
|
||||||
|
value?: number;
|
||||||
|
payload: {
|
||||||
|
time: string;
|
||||||
|
usage: number;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
|
||||||
|
if (active && payload && payload.length && payload[0]) {
|
||||||
|
return (
|
||||||
|
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
|
||||||
|
{payload[0].payload.time && (
|
||||||
|
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
|
||||||
|
)}
|
||||||
|
<p>{`CPU Usage: ${payload[0].payload.usage}%`}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,82 +1,105 @@
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
|
|
||||||
import {
|
import {
|
||||||
type ChartConfig,
|
Area,
|
||||||
ChartContainer,
|
AreaChart,
|
||||||
ChartTooltip,
|
CartesianGrid,
|
||||||
ChartTooltipContent,
|
Legend,
|
||||||
} from "@/components/ui/chart";
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
accumulativeData: DockerStatsJSON["disk"];
|
acummulativeData: DockerStatsJSON["disk"];
|
||||||
diskTotal: number;
|
diskTotal: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartConfig = {
|
export const DockerDiskChart = ({ acummulativeData, diskTotal }: Props) => {
|
||||||
usedGb: {
|
const transformedData = acummulativeData.map((item, index) => {
|
||||||
label: "Used (GB)",
|
return {
|
||||||
color: "hsl(var(--chart-3))",
|
time: item.time,
|
||||||
},
|
name: `Point ${index + 1}`,
|
||||||
} satisfies ChartConfig;
|
usedGb: +item.value.diskUsage,
|
||||||
|
totalGb: +item.value.diskTotal,
|
||||||
export const DockerDiskChart = ({ accumulativeData, diskTotal }: Props) => {
|
freeGb: item.value.diskFree,
|
||||||
const transformedData = accumulativeData.map((item, index) => ({
|
};
|
||||||
time: item.time,
|
});
|
||||||
name: `Point ${index + 1}`,
|
|
||||||
usedGb: +item.value.diskUsage,
|
|
||||||
totalGb: +item.value.diskTotal,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
|
<div className="mt-6 w-full h-[10rem]">
|
||||||
<AreaChart
|
<ResponsiveContainer>
|
||||||
data={transformedData}
|
<AreaChart
|
||||||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
data={transformedData}
|
||||||
>
|
margin={{
|
||||||
<defs>
|
top: 10,
|
||||||
<linearGradient id="fillDiskUsed" x1="0" y1="0" x2="0" y2="1">
|
right: 30,
|
||||||
<stop
|
left: 0,
|
||||||
offset="5%"
|
bottom: 0,
|
||||||
stopColor="var(--color-usedGb)"
|
}}
|
||||||
stopOpacity={0.8}
|
>
|
||||||
/>
|
<defs>
|
||||||
<stop
|
<linearGradient id="colorUsed" x1="0" y1="0" x2="0" y2="1">
|
||||||
offset="95%"
|
<stop offset="5%" stopColor="#6C28D9" stopOpacity={0.8} />
|
||||||
stopColor="var(--color-usedGb)"
|
<stop offset="95%" stopColor="#6C28D9" stopOpacity={0} />
|
||||||
stopOpacity={0.1}
|
</linearGradient>
|
||||||
/>
|
<linearGradient id="colorFree" x1="0" y1="0" x2="0" y2="1">
|
||||||
</linearGradient>
|
<stop offset="5%" stopColor="#6C28D9" stopOpacity={0.2} />
|
||||||
</defs>
|
<stop offset="95%" stopColor="#6C28D9" stopOpacity={0} />
|
||||||
<CartesianGrid vertical={false} />
|
</linearGradient>
|
||||||
<YAxis
|
</defs>
|
||||||
domain={[0, diskTotal]}
|
<YAxis stroke="#A1A1AA" domain={[0, diskTotal]} />
|
||||||
tickLine={false}
|
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
|
||||||
axisLine={false}
|
{/* @ts-ignore */}
|
||||||
tickFormatter={(value) => `${value} GB`}
|
<Tooltip content={<CustomTooltip />} />
|
||||||
/>
|
<Legend />
|
||||||
<ChartTooltip
|
<Area
|
||||||
cursor={false}
|
type="monotone"
|
||||||
content={
|
dataKey="usedGb"
|
||||||
<ChartTooltipContent
|
stroke="#6C28D9"
|
||||||
labelFormatter={(_, payload) => {
|
fillOpacity={1}
|
||||||
const time = payload?.[0]?.payload?.time;
|
fill="url(#colorUsed)"
|
||||||
return time ? format(new Date(time), "PPpp") : "";
|
name="Used GB"
|
||||||
}}
|
/>
|
||||||
formatter={(value) => {
|
<Area
|
||||||
return [`${value} GB`, "Used"];
|
type="monotone"
|
||||||
}}
|
dataKey="freeGb"
|
||||||
/>
|
stroke="#8884d8"
|
||||||
}
|
fillOpacity={1}
|
||||||
/>
|
fill="url(#colorFree)"
|
||||||
<Area
|
name="Free GB"
|
||||||
type="monotone"
|
/>
|
||||||
dataKey="usedGb"
|
</AreaChart>
|
||||||
stroke="var(--color-usedGb)"
|
</ResponsiveContainer>
|
||||||
fill="url(#fillDiskUsed)"
|
</div>
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ChartContainer>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
interface CustomTooltipProps {
|
||||||
|
active: boolean;
|
||||||
|
payload?: {
|
||||||
|
color?: string;
|
||||||
|
dataKey?: string;
|
||||||
|
value?: number;
|
||||||
|
payload: {
|
||||||
|
time: string;
|
||||||
|
usedGb: number;
|
||||||
|
freeGb: number;
|
||||||
|
totalGb: number;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
|
||||||
|
if (active && payload && payload.length && payload[0]) {
|
||||||
|
return (
|
||||||
|
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
|
||||||
|
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
|
||||||
|
<p>{`Disk usage: ${payload[0].payload.usedGb} GB`}</p>
|
||||||
|
<p>{`Disk free: ${payload[0].payload.freeGb} GB`}</p>
|
||||||
|
<p>{`Total disk: ${payload[0].payload.totalGb} GB`}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
import { Loader2, RefreshCw } from "lucide-react";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { Cell, Label, Pie, PieChart } from "recharts";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
type ChartConfig,
|
|
||||||
ChartContainer,
|
|
||||||
ChartLegend,
|
|
||||||
ChartLegendContent,
|
|
||||||
ChartTooltip,
|
|
||||||
ChartTooltipContent,
|
|
||||||
} from "@/components/ui/chart";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
const TYPE_TO_KEY: Record<string, string> = {
|
|
||||||
Images: "images",
|
|
||||||
Containers: "containers",
|
|
||||||
"Local Volumes": "volumes",
|
|
||||||
"Build Cache": "buildCache",
|
|
||||||
};
|
|
||||||
|
|
||||||
const chartConfig = {
|
|
||||||
value: {
|
|
||||||
label: "Size",
|
|
||||||
},
|
|
||||||
images: {
|
|
||||||
label: "Images",
|
|
||||||
color: "hsl(var(--chart-1))",
|
|
||||||
},
|
|
||||||
containers: {
|
|
||||||
label: "Containers",
|
|
||||||
color: "hsl(var(--chart-2))",
|
|
||||||
},
|
|
||||||
volumes: {
|
|
||||||
label: "Volumes",
|
|
||||||
color: "hsl(var(--chart-3))",
|
|
||||||
},
|
|
||||||
buildCache: {
|
|
||||||
label: "Build Cache",
|
|
||||||
color: "hsl(var(--chart-4))",
|
|
||||||
},
|
|
||||||
} satisfies ChartConfig;
|
|
||||||
|
|
||||||
const formatSize = (bytes: number): string => {
|
|
||||||
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
|
||||||
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
|
||||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
return `${bytes} B`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DockerDiskUsageChart = () => {
|
|
||||||
const { data, isLoading, refetch, isRefetching } =
|
|
||||||
api.settings.getDockerDiskUsage.useQuery(undefined, {
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { chartData, totalBytes } = useMemo(() => {
|
|
||||||
const items =
|
|
||||||
data
|
|
||||||
?.filter((item) => item.sizeBytes > 0)
|
|
||||||
.map((item) => {
|
|
||||||
const key = TYPE_TO_KEY[item.type] ?? item.type;
|
|
||||||
return {
|
|
||||||
name: key,
|
|
||||||
value: item.sizeBytes,
|
|
||||||
size: item.size,
|
|
||||||
active: item.active,
|
|
||||||
totalCount: item.totalCount,
|
|
||||||
reclaimable: item.reclaimable,
|
|
||||||
fill: `var(--color-${key})`,
|
|
||||||
};
|
|
||||||
}) ?? [];
|
|
||||||
return {
|
|
||||||
chartData: items,
|
|
||||||
totalBytes: items.reduce((sum, item) => sum + item.value, 0),
|
|
||||||
};
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-[16rem]">
|
|
||||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chartData.length === 0) {
|
|
||||||
return (
|
|
||||||
<p className="text-xs text-muted-foreground mt-4">
|
|
||||||
No Docker disk usage data available.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2 w-full">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Total: {formatSize(totalBytes)}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={() => refetch()}
|
|
||||||
disabled={isRefetching}
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={`size-3.5 ${isRefetching ? "animate-spin" : ""}`}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<ChartContainer
|
|
||||||
config={chartConfig}
|
|
||||||
className="mx-auto w-full max-h-[250px] [&_.recharts-pie-label-text]:fill-foreground"
|
|
||||||
>
|
|
||||||
<PieChart>
|
|
||||||
<ChartTooltip
|
|
||||||
content={
|
|
||||||
<ChartTooltipContent
|
|
||||||
nameKey="name"
|
|
||||||
formatter={(value, name) => {
|
|
||||||
const item = chartData.find((d) => d.name === name);
|
|
||||||
if (!item) return [formatSize(value as number), name];
|
|
||||||
return [
|
|
||||||
`${item.size} — ${item.active} active / ${item.totalCount} total — Reclaimable: ${item.reclaimable}`,
|
|
||||||
chartConfig[name as keyof typeof chartConfig]?.label ??
|
|
||||||
name,
|
|
||||||
];
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Pie
|
|
||||||
data={chartData}
|
|
||||||
dataKey="value"
|
|
||||||
nameKey="name"
|
|
||||||
innerRadius={60}
|
|
||||||
outerRadius={85}
|
|
||||||
strokeWidth={3}
|
|
||||||
stroke="hsl(var(--background))"
|
|
||||||
minAngle={15}
|
|
||||||
>
|
|
||||||
{chartData.map((entry) => (
|
|
||||||
<Cell key={entry.name} fill={entry.fill} />
|
|
||||||
))}
|
|
||||||
<Label
|
|
||||||
content={({ viewBox }) => {
|
|
||||||
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
|
||||||
return (
|
|
||||||
<text
|
|
||||||
x={viewBox.cx}
|
|
||||||
y={viewBox.cy}
|
|
||||||
textAnchor="middle"
|
|
||||||
dominantBaseline="middle"
|
|
||||||
>
|
|
||||||
<tspan
|
|
||||||
x={viewBox.cx}
|
|
||||||
y={(viewBox.cy || 0) - 8}
|
|
||||||
className="fill-foreground text-2xl font-bold"
|
|
||||||
>
|
|
||||||
{formatSize(totalBytes)}
|
|
||||||
</tspan>
|
|
||||||
<tspan
|
|
||||||
x={viewBox.cx}
|
|
||||||
y={(viewBox.cy || 0) + 14}
|
|
||||||
className="fill-muted-foreground text-xs"
|
|
||||||
>
|
|
||||||
Docker Usage
|
|
||||||
</tspan>
|
|
||||||
</text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Pie>
|
|
||||||
<ChartLegend content={<ChartLegendContent nameKey="name" />} />
|
|
||||||
</PieChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,87 +1,93 @@
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
|
|
||||||
import {
|
import {
|
||||||
type ChartConfig,
|
Area,
|
||||||
ChartContainer,
|
AreaChart,
|
||||||
ChartLegend,
|
CartesianGrid,
|
||||||
ChartLegendContent,
|
Legend,
|
||||||
ChartTooltip,
|
ResponsiveContainer,
|
||||||
ChartTooltipContent,
|
Tooltip,
|
||||||
} from "@/components/ui/chart";
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||||
import { convertMemoryToBytes } from "./show-free-container-monitoring";
|
import { convertMemoryToBytes } from "./show-free-container-monitoring";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
accumulativeData: DockerStatsJSON["memory"];
|
acummulativeData: DockerStatsJSON["memory"];
|
||||||
memoryLimitGB: number;
|
memoryLimitGB: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartConfig = {
|
|
||||||
usage: {
|
|
||||||
label: "Memory (GB)",
|
|
||||||
color: "hsl(var(--chart-2))",
|
|
||||||
},
|
|
||||||
} satisfies ChartConfig;
|
|
||||||
|
|
||||||
export const DockerMemoryChart = ({
|
export const DockerMemoryChart = ({
|
||||||
accumulativeData,
|
acummulativeData,
|
||||||
memoryLimitGB,
|
memoryLimitGB,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const transformedData = accumulativeData.map((item, index) => ({
|
const transformedData = acummulativeData.map((item, index) => {
|
||||||
time: item.time,
|
return {
|
||||||
name: `Point ${index + 1}`,
|
time: item.time,
|
||||||
// @ts-ignore
|
name: `Point ${index + 1}`,
|
||||||
usage: (convertMemoryToBytes(item.value.used) / 1024 ** 3).toFixed(2),
|
// @ts-ignore
|
||||||
}));
|
usage: (convertMemoryToBytes(item.value.used) / 1024 ** 3).toFixed(2),
|
||||||
|
};
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
|
<div className="mt-6 w-full h-[10rem]">
|
||||||
<AreaChart
|
<ResponsiveContainer>
|
||||||
data={transformedData}
|
<AreaChart
|
||||||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
data={transformedData}
|
||||||
>
|
margin={{
|
||||||
<defs>
|
top: 10,
|
||||||
<linearGradient id="fillMemory" x1="0" y1="0" x2="0" y2="1">
|
right: 30,
|
||||||
<stop
|
left: 0,
|
||||||
offset="5%"
|
bottom: 0,
|
||||||
stopColor="var(--color-usage)"
|
}}
|
||||||
stopOpacity={0.8}
|
>
|
||||||
/>
|
<defs>
|
||||||
<stop
|
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
|
||||||
offset="95%"
|
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
|
||||||
stopColor="var(--color-usage)"
|
<stop offset="95%" stopColor="white" stopOpacity={0} />
|
||||||
stopOpacity={0.1}
|
</linearGradient>
|
||||||
/>
|
</defs>
|
||||||
</linearGradient>
|
<YAxis stroke="#A1A1AA" domain={[0, +memoryLimitGB.toFixed(2)]} />
|
||||||
</defs>
|
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
|
||||||
<CartesianGrid vertical={false} />
|
{/* @ts-ignore */}
|
||||||
<YAxis
|
<Tooltip content={<CustomTooltip />} />
|
||||||
tickFormatter={(value) => `${value} GB`}
|
<Legend />
|
||||||
domain={[0, +memoryLimitGB.toFixed(2)]}
|
<Area
|
||||||
tickLine={false}
|
type="monotone"
|
||||||
axisLine={false}
|
dataKey="usage"
|
||||||
/>
|
stroke="#27272A"
|
||||||
<ChartTooltip
|
fillOpacity={1}
|
||||||
cursor={false}
|
fill="url(#colorUv)"
|
||||||
content={
|
/>
|
||||||
<ChartTooltipContent
|
</AreaChart>
|
||||||
labelFormatter={(_, payload) => {
|
</ResponsiveContainer>
|
||||||
const time = payload?.[0]?.payload?.time;
|
</div>
|
||||||
return time ? format(new Date(time), "PPpp") : "";
|
|
||||||
}}
|
|
||||||
formatter={(value) => [`${value} GB`, "Memory"]}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="usage"
|
|
||||||
stroke="var(--color-usage)"
|
|
||||||
fill="url(#fillMemory)"
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
<ChartLegend content={<ChartLegendContent />} />
|
|
||||||
</AreaChart>
|
|
||||||
</ChartContainer>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
interface CustomTooltipProps {
|
||||||
|
active: boolean;
|
||||||
|
payload?: {
|
||||||
|
color?: string;
|
||||||
|
dataKey?: string;
|
||||||
|
value?: number;
|
||||||
|
payload: {
|
||||||
|
time: string;
|
||||||
|
usage: number;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
|
||||||
|
if (active && payload && payload.length && payload[0] && payload[0].payload) {
|
||||||
|
return (
|
||||||
|
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
|
||||||
|
{payload[0].payload.time && (
|
||||||
|
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p>{`Memory usage: ${payload[0].payload.usage} GB`}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,99 +1,99 @@
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
|
|
||||||
import {
|
import {
|
||||||
type ChartConfig,
|
Area,
|
||||||
ChartContainer,
|
AreaChart,
|
||||||
ChartLegend,
|
CartesianGrid,
|
||||||
ChartLegendContent,
|
Legend,
|
||||||
ChartTooltip,
|
ResponsiveContainer,
|
||||||
ChartTooltipContent,
|
Tooltip,
|
||||||
} from "@/components/ui/chart";
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
import type { DockerStatsJSON } from "./show-free-container-monitoring";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
accumulativeData: DockerStatsJSON["network"];
|
acummulativeData: DockerStatsJSON["network"];
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartConfig = {
|
export const DockerNetworkChart = ({ acummulativeData }: Props) => {
|
||||||
inMB: {
|
const transformedData = acummulativeData.map((item, index) => {
|
||||||
label: "In (MB)",
|
return {
|
||||||
color: "hsl(var(--chart-1))",
|
time: item.time,
|
||||||
},
|
name: `Point ${index + 1}`,
|
||||||
outMB: {
|
inMB: item.value.inputMb,
|
||||||
label: "Out (MB)",
|
outMB: item.value.outputMb,
|
||||||
color: "hsl(var(--chart-2))",
|
};
|
||||||
},
|
});
|
||||||
} satisfies ChartConfig;
|
|
||||||
|
|
||||||
export const DockerNetworkChart = ({ accumulativeData }: Props) => {
|
|
||||||
const transformedData = accumulativeData.map((item, index) => ({
|
|
||||||
time: item.time,
|
|
||||||
name: `Point ${index + 1}`,
|
|
||||||
inMB: item.value.inputMb,
|
|
||||||
outMB: item.value.outputMb,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
|
<div className="mt-6 w-full h-[10rem]">
|
||||||
<AreaChart
|
<ResponsiveContainer>
|
||||||
data={transformedData}
|
<AreaChart
|
||||||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
data={transformedData}
|
||||||
>
|
margin={{
|
||||||
<defs>
|
top: 10,
|
||||||
<linearGradient id="fillNetIn" x1="0" y1="0" x2="0" y2="1">
|
right: 30,
|
||||||
<stop offset="5%" stopColor="var(--color-inMB)" stopOpacity={0.8} />
|
left: 0,
|
||||||
<stop
|
bottom: 0,
|
||||||
offset="95%"
|
}}
|
||||||
stopColor="var(--color-inMB)"
|
>
|
||||||
stopOpacity={0.1}
|
<defs>
|
||||||
/>
|
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
|
||||||
</linearGradient>
|
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
|
||||||
<linearGradient id="fillNetOut" x1="0" y1="0" x2="0" y2="1">
|
<stop offset="95%" stopColor="white" stopOpacity={0} />
|
||||||
<stop
|
</linearGradient>
|
||||||
offset="5%"
|
</defs>
|
||||||
stopColor="var(--color-outMB)"
|
<YAxis stroke="#A1A1AA" />
|
||||||
stopOpacity={0.8}
|
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
|
||||||
/>
|
{/* @ts-ignore */}
|
||||||
<stop
|
<Tooltip content={<CustomTooltip />} />
|
||||||
offset="95%"
|
<Legend />
|
||||||
stopColor="var(--color-outMB)"
|
<Area
|
||||||
stopOpacity={0.1}
|
type="monotone"
|
||||||
/>
|
dataKey="inMB"
|
||||||
</linearGradient>
|
stroke="#8884d8"
|
||||||
</defs>
|
fillOpacity={1}
|
||||||
<CartesianGrid vertical={false} />
|
fill="url(#colorUv)"
|
||||||
<YAxis tickLine={false} axisLine={false} />
|
name="In MB"
|
||||||
<ChartTooltip
|
/>
|
||||||
cursor={false}
|
<Area
|
||||||
content={
|
type="monotone"
|
||||||
<ChartTooltipContent
|
dataKey="outMB"
|
||||||
labelFormatter={(_, payload) => {
|
stroke="#82ca9d"
|
||||||
const time = payload?.[0]?.payload?.time;
|
fillOpacity={1}
|
||||||
return time ? format(new Date(time), "PPpp") : "";
|
fill="url(#colorUv)"
|
||||||
}}
|
name="Out MB"
|
||||||
formatter={(value, name) => {
|
/>
|
||||||
const label = name === "inMB" ? "In" : "Out";
|
</AreaChart>
|
||||||
return [`${value} MB`, label];
|
</ResponsiveContainer>
|
||||||
}}
|
</div>
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="inMB"
|
|
||||||
stroke="var(--color-inMB)"
|
|
||||||
fill="url(#fillNetIn)"
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="outMB"
|
|
||||||
stroke="var(--color-outMB)"
|
|
||||||
fill="url(#fillNetOut)"
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
<ChartLegend content={<ChartLegendContent />} />
|
|
||||||
</AreaChart>
|
|
||||||
</ChartContainer>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface CustomTooltipProps {
|
||||||
|
active: boolean;
|
||||||
|
payload?: {
|
||||||
|
color?: string;
|
||||||
|
dataKey?: string;
|
||||||
|
value?: number;
|
||||||
|
payload: {
|
||||||
|
time: string;
|
||||||
|
inMB: number;
|
||||||
|
outMB: number;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
|
||||||
|
if (active && payload && payload.length && payload[0]) {
|
||||||
|
return (
|
||||||
|
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
|
||||||
|
{payload[0].payload.time && (
|
||||||
|
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
|
||||||
|
)}
|
||||||
|
<p>{`In Usage: ${payload[0].payload.inMB} `}</p>
|
||||||
|
<p>{`Out Usage: ${payload[0].payload.outMB} `}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { api } from "@/utils/api";
|
|||||||
import { DockerBlockChart } from "./docker-block-chart";
|
import { DockerBlockChart } from "./docker-block-chart";
|
||||||
import { DockerCpuChart } from "./docker-cpu-chart";
|
import { DockerCpuChart } from "./docker-cpu-chart";
|
||||||
import { DockerDiskChart } from "./docker-disk-chart";
|
import { DockerDiskChart } from "./docker-disk-chart";
|
||||||
import { DockerDiskUsageChart } from "./docker-disk-usage-chart";
|
|
||||||
import { DockerMemoryChart } from "./docker-memory-chart";
|
import { DockerMemoryChart } from "./docker-memory-chart";
|
||||||
import { DockerNetworkChart } from "./docker-network-chart";
|
import { DockerNetworkChart } from "./docker-network-chart";
|
||||||
|
|
||||||
@@ -125,7 +124,7 @@ export const ContainerFreeMonitoring = ({
|
|||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const [accumulativeData, setAccumulativeData] = useState<DockerStatsJSON>({
|
const [acummulativeData, setAcummulativeData] = useState<DockerStatsJSON>({
|
||||||
cpu: [],
|
cpu: [],
|
||||||
memory: [],
|
memory: [],
|
||||||
block: [],
|
block: [],
|
||||||
@@ -137,7 +136,7 @@ export const ContainerFreeMonitoring = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentData(defaultData);
|
setCurrentData(defaultData);
|
||||||
|
|
||||||
setAccumulativeData({
|
setAcummulativeData({
|
||||||
cpu: [],
|
cpu: [],
|
||||||
memory: [],
|
memory: [],
|
||||||
block: [],
|
block: [],
|
||||||
@@ -156,7 +155,7 @@ export const ContainerFreeMonitoring = ({
|
|||||||
network: data.network[data.network.length - 1] ?? currentData.network,
|
network: data.network[data.network.length - 1] ?? currentData.network,
|
||||||
disk: data.disk[data.disk.length - 1] ?? currentData.disk,
|
disk: data.disk[data.disk.length - 1] ?? currentData.disk,
|
||||||
});
|
});
|
||||||
setAccumulativeData({
|
setAcummulativeData({
|
||||||
block: data?.block || [],
|
block: data?.block || [],
|
||||||
cpu: data?.cpu || [],
|
cpu: data?.cpu || [],
|
||||||
disk: data?.disk || [],
|
disk: data?.disk || [],
|
||||||
@@ -185,7 +184,7 @@ export const ContainerFreeMonitoring = ({
|
|||||||
setCurrentData(data);
|
setCurrentData(data);
|
||||||
|
|
||||||
const MAX_DATA_POINTS = 300;
|
const MAX_DATA_POINTS = 300;
|
||||||
setAccumulativeData((prevData) => ({
|
setAcummulativeData((prevData) => ({
|
||||||
cpu: [...prevData.cpu, data.cpu].slice(-MAX_DATA_POINTS),
|
cpu: [...prevData.cpu, data.cpu].slice(-MAX_DATA_POINTS),
|
||||||
memory: [...prevData.memory, data.memory].slice(-MAX_DATA_POINTS),
|
memory: [...prevData.memory, data.memory].slice(-MAX_DATA_POINTS),
|
||||||
block: [...prevData.block, data.block].slice(-MAX_DATA_POINTS),
|
block: [...prevData.block, data.block].slice(-MAX_DATA_POINTS),
|
||||||
@@ -229,7 +228,7 @@ export const ContainerFreeMonitoring = ({
|
|||||||
)}
|
)}
|
||||||
className="w-[100%]"
|
className="w-[100%]"
|
||||||
/>
|
/>
|
||||||
<DockerCpuChart accumulativeData={accumulativeData.cpu} />
|
<DockerCpuChart acummulativeData={acummulativeData.cpu} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -253,7 +252,7 @@ export const ContainerFreeMonitoring = ({
|
|||||||
className="w-[100%]"
|
className="w-[100%]"
|
||||||
/>
|
/>
|
||||||
<DockerMemoryChart
|
<DockerMemoryChart
|
||||||
accumulativeData={accumulativeData.memory}
|
acummulativeData={acummulativeData.memory}
|
||||||
memoryLimitGB={
|
memoryLimitGB={
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
convertMemoryToBytes(currentData.memory.value.total) /
|
convertMemoryToBytes(currentData.memory.value.total) /
|
||||||
@@ -278,25 +277,13 @@ export const ContainerFreeMonitoring = ({
|
|||||||
className="w-[100%]"
|
className="w-[100%]"
|
||||||
/>
|
/>
|
||||||
<DockerDiskChart
|
<DockerDiskChart
|
||||||
accumulativeData={accumulativeData.disk}
|
acummulativeData={acummulativeData.disk}
|
||||||
diskTotal={currentData.disk.value.diskTotal}
|
diskTotal={currentData.disk.value.diskTotal}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
{appName === "dokploy" && (
|
|
||||||
<Card className="bg-background">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Docker Disk Usage
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<DockerDiskUsageChart />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
@@ -307,7 +294,7 @@ export const ContainerFreeMonitoring = ({
|
|||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{`Read: ${currentData.block.value.readMb} / Write: ${currentData.block.value.writeMb} `}
|
{`Read: ${currentData.block.value.readMb} / Write: ${currentData.block.value.writeMb} `}
|
||||||
</span>
|
</span>
|
||||||
<DockerBlockChart accumulativeData={accumulativeData.block} />
|
<DockerBlockChart acummulativeData={acummulativeData.block} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -320,7 +307,7 @@ export const ContainerFreeMonitoring = ({
|
|||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{`In MB: ${currentData.network.value.inputMb} / Out MB: ${currentData.network.value.outputMb} `}
|
{`In MB: ${currentData.network.value.inputMb} / Out MB: ${currentData.network.value.outputMb} `}
|
||||||
</span>
|
</span>
|
||||||
<DockerNetworkChart accumulativeData={accumulativeData.network} />
|
<DockerNetworkChart acummulativeData={acummulativeData.network} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||||
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mysqlId: string;
|
mysqlId: string;
|
||||||
}
|
}
|
||||||
export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
|
export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
|
||||||
const { data } = api.mysql.one.useQuery({ mysqlId });
|
const { data } = api.mysql.one.useQuery({ mysqlId });
|
||||||
const utils = api.useUtils();
|
|
||||||
const { mutateAsync: changePassword } =
|
|
||||||
api.mysql.changePassword.useMutation();
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
@@ -33,43 +28,20 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Password</Label>
|
<Label>Password</Label>
|
||||||
<div className="flex flex-row gap-2 items-center">
|
<div className="flex flex-row gap-4">
|
||||||
<ToggleVisibilityInput
|
<ToggleVisibilityInput
|
||||||
disabled
|
disabled
|
||||||
value={data?.databasePassword}
|
value={data?.databasePassword}
|
||||||
/>
|
/>
|
||||||
<UpdateDatabasePassword
|
|
||||||
onUpdatePassword={async (newPassword) => {
|
|
||||||
await changePassword({
|
|
||||||
mysqlId,
|
|
||||||
password: newPassword,
|
|
||||||
type: "user",
|
|
||||||
});
|
|
||||||
toast.success("Password updated successfully");
|
|
||||||
utils.mysql.one.invalidate({ mysqlId });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Root Password</Label>
|
<Label>Root Password</Label>
|
||||||
<div className="flex flex-row gap-2 items-center">
|
<div className="flex flex-row gap-4">
|
||||||
<ToggleVisibilityInput
|
<ToggleVisibilityInput
|
||||||
disabled
|
disabled
|
||||||
value={data?.databaseRootPassword}
|
value={data?.databaseRootPassword}
|
||||||
/>
|
/>
|
||||||
<UpdateDatabasePassword
|
|
||||||
label="Root Password"
|
|
||||||
onUpdatePassword={async (newPassword) => {
|
|
||||||
await changePassword({
|
|
||||||
mysqlId,
|
|
||||||
password: newPassword,
|
|
||||||
type: "root",
|
|
||||||
});
|
|
||||||
toast.success("Root password updated successfully");
|
|
||||||
utils.mysql.one.invalidate({ mysqlId });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
|||||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
mariadb: () =>
|
mariadb: () =>
|
||||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
application: () =>
|
application: () =>
|
||||||
@@ -57,7 +56,6 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
|||||||
postgres: () => api.postgres.update.useMutation(),
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
redis: () => api.redis.update.useMutation(),
|
redis: () => api.redis.update.useMutation(),
|
||||||
mysql: () => api.mysql.update.useMutation(),
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
@@ -86,7 +84,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
|||||||
form.reset({
|
form.reset({
|
||||||
dockerImage: data.dockerImage,
|
dockerImage: data.dockerImage,
|
||||||
command: data.command || "",
|
command: data.command || "",
|
||||||
args: (data as any).args?.map((arg: string) => ({ value: arg })) || [],
|
args: data.args?.map((arg) => ({ value: arg })) || [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, form]);
|
}, [data, form]);
|
||||||
@@ -97,7 +95,6 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
|||||||
postgresId: id || "",
|
postgresId: id || "",
|
||||||
redisId: id || "",
|
redisId: id || "",
|
||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
dockerImage: formData?.dockerImage,
|
dockerImage: formData?.dockerImage,
|
||||||
command: formData?.command,
|
command: formData?.command,
|
||||||
@@ -147,14 +144,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Command</FormLabel>
|
<FormLabel>Command</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input placeholder="/bin/sh" {...field} />
|
||||||
placeholder={
|
|
||||||
type === "libsql"
|
|
||||||
? "sqld --db-path iku.db --http-listen-addr 0.0.0.0:8080 --grpc-listen-addr 0.0.0.0:5001 --admin-listen-addr 0.0.0.0:5000"
|
|
||||||
: "Custom command"
|
|
||||||
}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||||
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
postgresId: string;
|
postgresId: string;
|
||||||
}
|
}
|
||||||
export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
|
export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||||
const { data } = api.postgres.one.useQuery({ postgresId });
|
const { data } = api.postgres.one.useQuery({ postgresId });
|
||||||
const utils = api.useUtils();
|
|
||||||
const { mutateAsync: changePassword } =
|
|
||||||
api.postgres.changePassword.useMutation();
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
@@ -33,21 +28,11 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Password</Label>
|
<Label>Password</Label>
|
||||||
<div className="flex flex-row gap-2 items-center">
|
<div className="flex flex-row gap-4">
|
||||||
<ToggleVisibilityInput
|
<ToggleVisibilityInput
|
||||||
value={data?.databasePassword}
|
value={data?.databasePassword}
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
<UpdateDatabasePassword
|
|
||||||
onUpdatePassword={async (newPassword) => {
|
|
||||||
await changePassword({
|
|
||||||
postgresId,
|
|
||||||
password: newPassword,
|
|
||||||
});
|
|
||||||
toast.success("Password updated successfully");
|
|
||||||
utils.postgres.one.invalidate({ postgresId });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { slugify } from "@/lib/slug";
|
import { slugify } from "@/lib/slug";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
|
|
||||||
|
|
||||||
const AddTemplateSchema = z.object({
|
const AddTemplateSchema = z.object({
|
||||||
name: z.string().min(1, {
|
name: z.string().min(1, {
|
||||||
@@ -54,8 +53,9 @@ const AddTemplateSchema = z.object({
|
|||||||
.min(1, {
|
.min(1, {
|
||||||
message: "App name is required",
|
message: "App name is required",
|
||||||
})
|
})
|
||||||
.regex(APP_NAME_REGEX, {
|
.regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
|
||||||
message: APP_NAME_MESSAGE,
|
message:
|
||||||
|
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
|
||||||
}),
|
}),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
serverId: z.string().optional(),
|
serverId: z.string().optional(),
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { slugify } from "@/lib/slug";
|
import { slugify } from "@/lib/slug";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
|
|
||||||
|
|
||||||
const AddComposeSchema = z.object({
|
const AddComposeSchema = z.object({
|
||||||
composeType: z.enum(["docker-compose", "stack"]).optional(),
|
composeType: z.enum(["docker-compose", "stack"]).optional(),
|
||||||
@@ -55,8 +54,9 @@ const AddComposeSchema = z.object({
|
|||||||
.min(1, {
|
.min(1, {
|
||||||
message: "App name is required",
|
message: "App name is required",
|
||||||
})
|
})
|
||||||
.regex(APP_NAME_REGEX, {
|
.regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
|
||||||
message: APP_NAME_MESSAGE,
|
message:
|
||||||
|
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
|
||||||
}),
|
}),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
serverId: z.string().optional(),
|
serverId: z.string().optional(),
|
||||||
@@ -78,6 +78,9 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
|||||||
const { mutateAsync, isPending, error, isError } =
|
const { mutateAsync, isPending, error, isError } =
|
||||||
api.compose.create.useMutation();
|
api.compose.create.useMutation();
|
||||||
|
|
||||||
|
// Get environment data to extract projectId
|
||||||
|
const { data: environment } = api.environment.one.useQuery({ environmentId });
|
||||||
|
|
||||||
const hasServers = servers && servers.length > 0;
|
const hasServers = servers && servers.length > 0;
|
||||||
// Show dropdown logic based on cloud environment
|
// Show dropdown logic based on cloud environment
|
||||||
// Cloud: show only if there are remote servers (no Dokploy option)
|
// Cloud: show only if there are remote servers (no Dokploy option)
|
||||||
@@ -114,8 +117,6 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
|||||||
await utils.environment.one.invalidate({
|
await utils.environment.one.invalidate({
|
||||||
environmentId,
|
environmentId,
|
||||||
});
|
});
|
||||||
// Invalidate the project query to refresh the project data for the advance-breadcrumb
|
|
||||||
await utils.project.all.invalidate();
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error creating the compose");
|
toast.error("Error creating the compose");
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useForm } from "react-hook-form";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
LibsqlIcon,
|
|
||||||
MariadbIcon,
|
MariadbIcon,
|
||||||
MongodbIcon,
|
MongodbIcon,
|
||||||
MysqlIcon,
|
MysqlIcon,
|
||||||
@@ -52,13 +51,11 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { slugify } from "@/lib/slug";
|
import { slugify } from "@/lib/slug";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
|
|
||||||
|
|
||||||
type DbType = z.infer<typeof mySchema>["type"];
|
type DbType = z.infer<typeof mySchema>["type"];
|
||||||
|
|
||||||
const dockerImageDefaultPlaceholder: Record<DbType, string> = {
|
const dockerImageDefaultPlaceholder: Record<DbType, string> = {
|
||||||
mongo: "mongo:8",
|
mongo: "mongo:7",
|
||||||
libsql: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
|
|
||||||
mariadb: "mariadb:11",
|
mariadb: "mariadb:11",
|
||||||
mysql: "mysql:8",
|
mysql: "mysql:8",
|
||||||
postgres: "postgres:18",
|
postgres: "postgres:18",
|
||||||
@@ -69,9 +66,8 @@ const databasesUserDefaultPlaceholder: Record<
|
|||||||
Exclude<DbType, "redis">,
|
Exclude<DbType, "redis">,
|
||||||
string
|
string
|
||||||
> = {
|
> = {
|
||||||
libsql: "libsql",
|
|
||||||
mariadb: "mariadb",
|
|
||||||
mongo: "mongo",
|
mongo: "mongo",
|
||||||
|
mariadb: "mariadb",
|
||||||
mysql: "mysql",
|
mysql: "mysql",
|
||||||
postgres: "postgres",
|
postgres: "postgres",
|
||||||
};
|
};
|
||||||
@@ -83,8 +79,9 @@ const baseDatabaseSchema = z.object({
|
|||||||
.min(1, {
|
.min(1, {
|
||||||
message: "App name is required",
|
message: "App name is required",
|
||||||
})
|
})
|
||||||
.regex(APP_NAME_REGEX, {
|
.regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
|
||||||
message: APP_NAME_MESSAGE,
|
message:
|
||||||
|
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
|
||||||
}),
|
}),
|
||||||
databasePassword: z
|
databasePassword: z
|
||||||
.string()
|
.string()
|
||||||
@@ -97,88 +94,56 @@ const baseDatabaseSchema = z.object({
|
|||||||
serverId: z.string().nullable(),
|
serverId: z.string().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mySchema = z
|
const mySchema = z.discriminatedUnion("type", [
|
||||||
.discriminatedUnion("type", [
|
z
|
||||||
z
|
.object({
|
||||||
.object({
|
type: z.literal("postgres"),
|
||||||
type: z.literal("libsql"),
|
databaseName: z.string().default("postgres"),
|
||||||
dockerImage: z
|
databaseUser: z.string().default("postgres"),
|
||||||
.string()
|
})
|
||||||
.default("ghcr.io/tursodatabase/libsql-server:v0.24.32"),
|
.merge(baseDatabaseSchema),
|
||||||
databaseUser: z.string().default("libsql"),
|
z
|
||||||
sqldNode: z.enum(["primary", "replica"]).default("primary"),
|
.object({
|
||||||
sqldPrimaryUrl: z.string().optional(),
|
type: z.literal("mongo"),
|
||||||
enableNamespaces: z.boolean().default(false),
|
databaseUser: z.string().default("mongo"),
|
||||||
})
|
replicaSets: z.boolean().default(false),
|
||||||
.merge(baseDatabaseSchema),
|
})
|
||||||
z
|
.merge(baseDatabaseSchema),
|
||||||
.object({
|
z
|
||||||
type: z.literal("mariadb"),
|
.object({
|
||||||
dockerImage: z.string().default("mariadb:4"),
|
type: z.literal("redis"),
|
||||||
databaseRootPassword: z
|
})
|
||||||
.string()
|
.merge(baseDatabaseSchema),
|
||||||
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
|
z
|
||||||
message:
|
.object({
|
||||||
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
|
type: z.literal("mysql"),
|
||||||
})
|
databaseRootPassword: z
|
||||||
.optional(),
|
.string()
|
||||||
databaseUser: z.string().default("mariadb"),
|
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
|
||||||
databaseName: z.string().default("mariadb"),
|
|
||||||
})
|
|
||||||
.merge(baseDatabaseSchema),
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
type: z.literal("mongo"),
|
|
||||||
databaseUser: z.string().default("mongo"),
|
|
||||||
replicaSets: z.boolean().default(false),
|
|
||||||
})
|
|
||||||
.merge(baseDatabaseSchema),
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
type: z.literal("mysql"),
|
|
||||||
databaseRootPassword: z
|
|
||||||
.string()
|
|
||||||
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
|
|
||||||
message:
|
|
||||||
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
databaseUser: z.string().default("mysql"),
|
|
||||||
databaseName: z.string().default("mysql"),
|
|
||||||
})
|
|
||||||
.merge(baseDatabaseSchema),
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
type: z.literal("postgres"),
|
|
||||||
databaseName: z.string().default("postgres"),
|
|
||||||
databaseUser: z.string().default("postgres"),
|
|
||||||
})
|
|
||||||
.merge(baseDatabaseSchema),
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
type: z.literal("redis"),
|
|
||||||
})
|
|
||||||
.merge(baseDatabaseSchema),
|
|
||||||
])
|
|
||||||
.superRefine((data, ctx) => {
|
|
||||||
if (data.type === "libsql") {
|
|
||||||
if (data.sqldNode === "replica" && !data.sqldPrimaryUrl) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: ["sqldPrimaryUrl"],
|
|
||||||
message: "sqldPrimaryUrl is required when sqldNode is 'replica'.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (data.sqldNode !== "replica" && data.sqldPrimaryUrl) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: ["sqldPrimaryUrl"],
|
|
||||||
message:
|
message:
|
||||||
"sqldPrimaryUrl should not be provided when sqldNode is not 'replica'.",
|
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
|
||||||
});
|
})
|
||||||
}
|
.optional(),
|
||||||
}
|
databaseUser: z.string().default("mysql"),
|
||||||
});
|
databaseName: z.string().default("mysql"),
|
||||||
|
})
|
||||||
|
.merge(baseDatabaseSchema),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
type: z.literal("mariadb"),
|
||||||
|
dockerImage: z.string().default("mariadb:4"),
|
||||||
|
databaseRootPassword: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
|
||||||
|
message:
|
||||||
|
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
databaseUser: z.string().default("mariadb"),
|
||||||
|
databaseName: z.string().default("mariadb"),
|
||||||
|
})
|
||||||
|
.merge(baseDatabaseSchema),
|
||||||
|
]);
|
||||||
|
|
||||||
const databasesMap = {
|
const databasesMap = {
|
||||||
postgres: {
|
postgres: {
|
||||||
@@ -201,10 +166,6 @@ const databasesMap = {
|
|||||||
icon: <RedisIcon />,
|
icon: <RedisIcon />,
|
||||||
label: "Redis",
|
label: "Redis",
|
||||||
},
|
},
|
||||||
libsql: {
|
|
||||||
icon: <LibsqlIcon className="size-10" />,
|
|
||||||
label: "libSQL",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type AddDatabase = z.infer<typeof mySchema>;
|
type AddDatabase = z.infer<typeof mySchema>;
|
||||||
@@ -220,12 +181,11 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
const slug = slugify(projectName);
|
const slug = slugify(projectName);
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||||
const libsqlMutation = api.libsql.create.useMutation();
|
|
||||||
const mariadbMutation = api.mariadb.create.useMutation();
|
|
||||||
const mongoMutation = api.mongo.create.useMutation();
|
|
||||||
const mysqlMutation = api.mysql.create.useMutation();
|
|
||||||
const postgresMutation = api.postgres.create.useMutation();
|
const postgresMutation = api.postgres.create.useMutation();
|
||||||
|
const mongoMutation = api.mongo.create.useMutation();
|
||||||
const redisMutation = api.redis.create.useMutation();
|
const redisMutation = api.redis.create.useMutation();
|
||||||
|
const mariadbMutation = api.mariadb.create.useMutation();
|
||||||
|
const mysqlMutation = api.mysql.create.useMutation();
|
||||||
|
|
||||||
// Get environment data to extract projectId
|
// Get environment data to extract projectId
|
||||||
const { data: environment } = api.environment.one.useQuery({ environmentId });
|
const { data: environment } = api.environment.one.useQuery({ environmentId });
|
||||||
@@ -250,15 +210,13 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
},
|
},
|
||||||
resolver: zodResolver(mySchema),
|
resolver: zodResolver(mySchema),
|
||||||
});
|
});
|
||||||
const sqldNode = form.watch("sqldNode");
|
|
||||||
const type = form.watch("type");
|
const type = form.watch("type");
|
||||||
const activeMutation = {
|
const activeMutation = {
|
||||||
libsql: libsqlMutation,
|
|
||||||
mariadb: mariadbMutation,
|
|
||||||
mongo: mongoMutation,
|
|
||||||
mysql: mysqlMutation,
|
|
||||||
postgres: postgresMutation,
|
postgres: postgresMutation,
|
||||||
|
mongo: mongoMutation,
|
||||||
redis: redisMutation,
|
redis: redisMutation,
|
||||||
|
mariadb: mariadbMutation,
|
||||||
|
mysql: mysqlMutation,
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (data: AddDatabase) => {
|
const onSubmit = async (data: AddDatabase) => {
|
||||||
@@ -275,23 +233,12 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
description: data.description,
|
description: data.description,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (data.type === "libsql") {
|
if (data.type === "postgres") {
|
||||||
promise = libsqlMutation.mutateAsync({
|
promise = postgresMutation.mutateAsync({
|
||||||
...commonParams,
|
|
||||||
sqldNode: data.sqldNode,
|
|
||||||
sqldPrimaryUrl: data.sqldPrimaryUrl ?? null,
|
|
||||||
enableNamespaces: data.enableNamespaces,
|
|
||||||
databasePassword: data.databasePassword,
|
|
||||||
databaseUser:
|
|
||||||
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
|
||||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
|
||||||
});
|
|
||||||
} else if (data.type === "mariadb") {
|
|
||||||
promise = mariadbMutation.mutateAsync({
|
|
||||||
...commonParams,
|
...commonParams,
|
||||||
databasePassword: data.databasePassword,
|
databasePassword: data.databasePassword,
|
||||||
databaseRootPassword: data.databaseRootPassword || "",
|
databaseName: data.databaseName || "postgres",
|
||||||
databaseName: data.databaseName || "mariadb",
|
|
||||||
databaseUser:
|
databaseUser:
|
||||||
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
||||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||||
@@ -305,6 +252,22 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||||
replicaSets: data.replicaSets,
|
replicaSets: data.replicaSets,
|
||||||
});
|
});
|
||||||
|
} else if (data.type === "redis") {
|
||||||
|
promise = redisMutation.mutateAsync({
|
||||||
|
...commonParams,
|
||||||
|
databasePassword: data.databasePassword,
|
||||||
|
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||||
|
});
|
||||||
|
} else if (data.type === "mariadb") {
|
||||||
|
promise = mariadbMutation.mutateAsync({
|
||||||
|
...commonParams,
|
||||||
|
databasePassword: data.databasePassword,
|
||||||
|
databaseRootPassword: data.databaseRootPassword || "",
|
||||||
|
databaseName: data.databaseName || "mariadb",
|
||||||
|
databaseUser:
|
||||||
|
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
||||||
|
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||||
|
});
|
||||||
} else if (data.type === "mysql") {
|
} else if (data.type === "mysql") {
|
||||||
promise = mysqlMutation.mutateAsync({
|
promise = mysqlMutation.mutateAsync({
|
||||||
...commonParams,
|
...commonParams,
|
||||||
@@ -315,21 +278,6 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
||||||
databaseRootPassword: data.databaseRootPassword || "",
|
databaseRootPassword: data.databaseRootPassword || "",
|
||||||
});
|
});
|
||||||
} else if (data.type === "postgres") {
|
|
||||||
promise = postgresMutation.mutateAsync({
|
|
||||||
...commonParams,
|
|
||||||
databasePassword: data.databasePassword,
|
|
||||||
databaseName: data.databaseName || "postgres",
|
|
||||||
databaseUser:
|
|
||||||
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
|
||||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
|
||||||
});
|
|
||||||
} else if (data.type === "redis") {
|
|
||||||
promise = redisMutation.mutateAsync({
|
|
||||||
...commonParams,
|
|
||||||
databasePassword: data.databasePassword,
|
|
||||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (promise) {
|
if (promise) {
|
||||||
@@ -357,7 +305,6 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={visible} onOpenChange={setVisible}>
|
<Dialog open={visible} onOpenChange={setVisible}>
|
||||||
<DialogTrigger className="w-full">
|
<DialogTrigger className="w-full">
|
||||||
@@ -559,8 +506,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{(type === "mariadb" ||
|
{(type === "mysql" ||
|
||||||
type === "mysql" ||
|
type === "mariadb" ||
|
||||||
type === "postgres") && (
|
type === "postgres") && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -577,101 +524,10 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{(type === "mysql" ||
|
||||||
{type === "libsql" && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="sqldNode"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Sqld Node</FormLabel>
|
|
||||||
<Select
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value || "primary"}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={"primary"} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{["primary", "replica"].map((node) => (
|
|
||||||
<SelectItem key={node} value={node}>
|
|
||||||
{node.charAt(0).toUpperCase() + node.slice(1)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{type === "libsql" && sqldNode === "replica" && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="sqldPrimaryUrl"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Sqld Primary URL</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder={"https://<host>:<port>"}
|
|
||||||
autoComplete="off"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{type === "libsql" && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="enableNamespaces"
|
|
||||||
render={({ field }) => {
|
|
||||||
console.log(field.value);
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Enable Namespaces</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Select
|
|
||||||
onValueChange={(value) =>
|
|
||||||
field.onChange(Boolean(value))
|
|
||||||
}
|
|
||||||
defaultValue={
|
|
||||||
field.value ? String(field.value) : "false"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={"false"} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{["false", "true"].map((node) => (
|
|
||||||
<SelectItem key={node} value={node}>
|
|
||||||
{node.charAt(0).toUpperCase() +
|
|
||||||
node.slice(1)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{(type === "libsql" ||
|
|
||||||
type === "mariadb" ||
|
type === "mariadb" ||
|
||||||
type === "mongo" ||
|
type === "postgres" ||
|
||||||
type === "mysql" ||
|
type === "mongo") && (
|
||||||
type === "postgres") && (
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="databaseUser"
|
name="databaseUser"
|
||||||
@@ -712,7 +568,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{(type === "mariadb" || type === "mysql") && (
|
{(type === "mysql" || type === "mariadb") && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="databaseRootPassword"
|
name="databaseRootPassword"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
BookText,
|
BookText,
|
||||||
Bookmark,
|
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
Globe,
|
Globe,
|
||||||
@@ -83,7 +82,6 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed");
|
const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed");
|
||||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
const [showBookmarksOnly, setShowBookmarksOnly] = useState(false);
|
|
||||||
const [customBaseUrl, setCustomBaseUrl] = useState<string | undefined>(() => {
|
const [customBaseUrl, setCustomBaseUrl] = useState<string | undefined>(() => {
|
||||||
// Try to get from props first, then localStorage
|
// Try to get from props first, then localStorage
|
||||||
if (baseUrl) return baseUrl;
|
if (baseUrl) return baseUrl;
|
||||||
@@ -124,45 +122,8 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
enabled: open,
|
enabled: open,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: bookmarkIds = [], isLoading: isLoadingBookmarks } =
|
|
||||||
api.user.getBookmarkedTemplates.useQuery(undefined, {
|
|
||||||
enabled: open,
|
|
||||||
});
|
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const { mutateAsync: toggleBookmark } =
|
|
||||||
api.user.toggleTemplateBookmark.useMutation({
|
|
||||||
onMutate: async ({ templateId }) => {
|
|
||||||
await utils.user.getBookmarkedTemplates.cancel();
|
|
||||||
const previousBookmarks = utils.user.getBookmarkedTemplates.getData();
|
|
||||||
|
|
||||||
utils.user.getBookmarkedTemplates.setData(undefined, (old = []) => {
|
|
||||||
if (old.includes(templateId)) {
|
|
||||||
return old.filter((id) => id !== templateId);
|
|
||||||
}
|
|
||||||
return [...old, templateId];
|
|
||||||
});
|
|
||||||
|
|
||||||
return { previousBookmarks };
|
|
||||||
},
|
|
||||||
onError: (err, variables, context) => {
|
|
||||||
if (context?.previousBookmarks) {
|
|
||||||
utils.user.getBookmarkedTemplates.setData(
|
|
||||||
undefined,
|
|
||||||
context.previousBookmarks,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
toast.error("Failed to update bookmark");
|
|
||||||
},
|
|
||||||
onSuccess: (data) => {
|
|
||||||
toast.success(
|
|
||||||
data.isBookmarked ? "Added to bookmarks" : "Removed from bookmarks",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const [serverId, setServerId] = useState<string | undefined>(undefined);
|
const [serverId, setServerId] = useState<string | undefined>(undefined);
|
||||||
const { mutateAsync, isPending, error, isError } =
|
const { mutateAsync, isPending, error, isError } =
|
||||||
api.compose.deployTemplate.useMutation();
|
api.compose.deployTemplate.useMutation();
|
||||||
@@ -176,9 +137,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
query === "" ||
|
query === "" ||
|
||||||
template.name.toLowerCase().includes(query.toLowerCase()) ||
|
template.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
template.description.toLowerCase().includes(query.toLowerCase());
|
template.description.toLowerCase().includes(query.toLowerCase());
|
||||||
const matchesBookmarks =
|
return matchesTags && matchesQuery;
|
||||||
!showBookmarksOnly || bookmarkIds.includes(template.id);
|
|
||||||
return matchesTags && matchesQuery && matchesBookmarks;
|
|
||||||
}) || [];
|
}) || [];
|
||||||
|
|
||||||
const hasServers = servers && servers.length > 0;
|
const hasServers = servers && servers.length > 0;
|
||||||
@@ -187,14 +146,6 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
|
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
|
||||||
const shouldShowServerDropdown = hasServers;
|
const shouldShowServerDropdown = hasServers;
|
||||||
|
|
||||||
const handleToggleBookmark = async (
|
|
||||||
e: React.MouseEvent,
|
|
||||||
templateId: string,
|
|
||||||
) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
await toggleBookmark({ templateId });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger className="w-full">
|
<DialogTrigger className="w-full">
|
||||||
@@ -292,20 +243,6 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<Button
|
|
||||||
variant={showBookmarksOnly ? "default" : "outline"}
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setShowBookmarksOnly(!showBookmarksOnly)}
|
|
||||||
className="h-9 w-9 flex-shrink-0"
|
|
||||||
disabled={isLoadingBookmarks}
|
|
||||||
>
|
|
||||||
<Bookmark
|
|
||||||
className={cn(
|
|
||||||
"size-4",
|
|
||||||
showBookmarksOnly && "fill-current",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -362,19 +299,11 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : templates.length === 0 ? (
|
) : templates.length === 0 ? (
|
||||||
<div className="flex flex-col justify-center items-center w-full gap-2 min-h-[50vh]">
|
<div className="flex justify-center items-center w-full gap-2 min-h-[50vh]">
|
||||||
<SearchIcon className="text-muted-foreground size-6" />
|
<SearchIcon className="text-muted-foreground size-6" />
|
||||||
<div className="text-xl font-medium text-muted-foreground">
|
<div className="text-xl font-medium text-muted-foreground">
|
||||||
{showBookmarksOnly
|
No templates found
|
||||||
? "No bookmarked templates found"
|
|
||||||
: "No templates found"}
|
|
||||||
</div>
|
</div>
|
||||||
{showBookmarksOnly && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Click the bookmark icon on templates to add them to
|
|
||||||
bookmarks
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
@@ -394,32 +323,15 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
viewMode === "detailed" && "h-[400px]",
|
viewMode === "detailed" && "h-[400px]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="absolute top-2 left-2 z-10">
|
<Badge className="absolute top-2 right-2" variant="blue">
|
||||||
<Button
|
{template?.version}
|
||||||
variant="ghost"
|
</Badge>
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 bg-background/80 backdrop-blur-sm hover:bg-background"
|
|
||||||
onClick={(e) => handleToggleBookmark(e, template.id)}
|
|
||||||
>
|
|
||||||
<Bookmark
|
|
||||||
className={cn(
|
|
||||||
"size-4",
|
|
||||||
bookmarkIds.includes(template.id) &&
|
|
||||||
"fill-yellow-400 text-yellow-400",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="absolute top-2 right-2">
|
|
||||||
<Badge variant="blue">{template?.version}</Badge>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-none p-6 pb-3 flex flex-col items-center gap-4 bg-muted/30",
|
"flex-none p-6 pb-3 flex flex-col items-center gap-4 bg-muted/30",
|
||||||
viewMode === "detailed" && "border-b",
|
viewMode === "detailed" && "border-b",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/** biome-ignore lint/performance/noImgElement: this is a valid use for img tag */}
|
|
||||||
<img
|
<img
|
||||||
src={`${customBaseUrl || "https://templates.dokploy.com/"}/blueprints/${template?.id}/${template?.logo}`}
|
src={`${customBaseUrl || "https://templates.dokploy.com/"}/blueprints/${template?.id}/${template?.logo}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -92,8 +92,6 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
|
|
||||||
toast.success("Environment created successfully");
|
toast.success("Environment created successfully");
|
||||||
utils.environment.byProjectId.invalidate({ projectId });
|
utils.environment.byProjectId.invalidate({ projectId });
|
||||||
// Invalidate the project query to refresh the project data for the advance-breadcrumb
|
|
||||||
utils.project.all.invalidate();
|
|
||||||
setIsCreateDialogOpen(false);
|
setIsCreateDialogOpen(false);
|
||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
|
|||||||
@@ -28,14 +28,13 @@ export type Services = {
|
|||||||
serverId?: string | null;
|
serverId?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
type:
|
type:
|
||||||
| "application"
|
|
||||||
| "compose"
|
|
||||||
| "libsql"
|
|
||||||
| "mariadb"
|
| "mariadb"
|
||||||
| "mongo"
|
| "application"
|
||||||
| "mysql"
|
|
||||||
| "postgres"
|
| "postgres"
|
||||||
| "redis";
|
| "mysql"
|
||||||
|
| "mongo"
|
||||||
|
| "redis"
|
||||||
|
| "compose";
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -88,12 +88,7 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
|
|||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (
|
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending && isOpen) {
|
||||||
(e.ctrlKey || e.metaKey) &&
|
|
||||||
e.code === "KeyS" &&
|
|
||||||
!isPending &&
|
|
||||||
isOpen
|
|
||||||
) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useForm } from "react-hook-form";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { TagSelector } from "@/components/shared/tag-selector";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -63,7 +62,6 @@ interface Props {
|
|||||||
export const HandleProject = ({ projectId }: Props) => {
|
export const HandleProject = ({ projectId }: Props) => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const { mutateAsync, error, isError } = projectId
|
const { mutateAsync, error, isError } = projectId
|
||||||
? api.project.update.useMutation()
|
? api.project.update.useMutation()
|
||||||
@@ -77,10 +75,6 @@ export const HandleProject = ({ projectId }: Props) => {
|
|||||||
enabled: !!projectId,
|
enabled: !!projectId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: availableTags = [] } = api.tag.all.useQuery();
|
|
||||||
const bulkAssignMutation = api.tag.bulkAssign.useMutation();
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const form = useForm<AddProject>({
|
const form = useForm<AddProject>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -95,13 +89,6 @@ export const HandleProject = ({ projectId }: Props) => {
|
|||||||
description: data?.description ?? "",
|
description: data?.description ?? "",
|
||||||
name: data?.name ?? "",
|
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]);
|
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
||||||
|
|
||||||
const onSubmit = async (data: AddProject) => {
|
const onSubmit = async (data: AddProject) => {
|
||||||
@@ -111,26 +98,12 @@ export const HandleProject = ({ projectId }: Props) => {
|
|||||||
projectId: projectId || "",
|
projectId: projectId || "",
|
||||||
})
|
})
|
||||||
.then(async (data) => {
|
.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();
|
await utils.project.all.invalidate();
|
||||||
toast.success(projectId ? "Project Updated" : "Project Created");
|
toast.success(projectId ? "Project Updated" : "Project Created");
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
|
const projectIdToUse =
|
||||||
|
data && "project" in data ? data.project.projectId : undefined;
|
||||||
const environmentIdToUse =
|
const environmentIdToUse =
|
||||||
data && "environment" in data
|
data && "environment" in data
|
||||||
? data.environment.environmentId
|
? data.environment.environmentId
|
||||||
@@ -217,20 +190,6 @@ export const HandleProject = ({ projectId }: Props) => {
|
|||||||
</FormItem>
|
</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>
|
</form>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -87,12 +87,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
|
|||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (
|
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending && isOpen) {
|
||||||
(e.ctrlKey || e.metaKey) &&
|
|
||||||
e.code === "KeyS" &&
|
|
||||||
!isPending &&
|
|
||||||
isOpen
|
|
||||||
) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ import { toast } from "sonner";
|
|||||||
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
||||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||||
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
|
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
|
||||||
import { TagBadge } from "@/components/shared/tag-badge";
|
|
||||||
import { TagFilter } from "@/components/shared/tag-filter";
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -51,6 +49,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { TimeBadge } from "@/components/ui/time-badge";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { useDebounce } from "@/utils/hooks/use-debounce";
|
import { useDebounce } from "@/utils/hooks/use-debounce";
|
||||||
import { HandleProject } from "./handle-project";
|
import { HandleProject } from "./handle-project";
|
||||||
@@ -64,7 +63,6 @@ export const ShowProjects = () => {
|
|||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
const { mutateAsync } = api.project.remove.useMutation();
|
const { mutateAsync } = api.project.remove.useMutation();
|
||||||
const { data: availableTags } = api.tag.all.useQuery();
|
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState(
|
const [searchQuery, setSearchQuery] = useState(
|
||||||
router.isReady && typeof router.query.q === "string" ? router.query.q : "",
|
router.isReady && typeof router.query.q === "string" ? router.query.q : "",
|
||||||
@@ -78,31 +76,10 @@ export const ShowProjects = () => {
|
|||||||
return "createdAt-desc";
|
return "createdAt-desc";
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>(() => {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const saved = localStorage.getItem("projectsTagFilter");
|
|
||||||
return saved ? JSON.parse(saved) : [];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("projectsSort", sortBy);
|
localStorage.setItem("projectsSort", sortBy);
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!router.isReady) return;
|
if (!router.isReady) return;
|
||||||
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
|
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
|
||||||
@@ -130,7 +107,7 @@ export const ShowProjects = () => {
|
|||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
|
|
||||||
let filtered = data.filter(
|
const filtered = data.filter(
|
||||||
(project) =>
|
(project) =>
|
||||||
project.name
|
project.name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -140,15 +117,6 @@ export const ShowProjects = () => {
|
|||||||
.includes(debouncedSearchQuery.toLowerCase()),
|
.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
|
// Then sort the filtered results
|
||||||
const [field, direction] = sortBy.split("-");
|
const [field, direction] = sortBy.split("-");
|
||||||
return [...filtered].sort((a, b) => {
|
return [...filtered].sort((a, b) => {
|
||||||
@@ -194,7 +162,7 @@ export const ShowProjects = () => {
|
|||||||
}
|
}
|
||||||
return direction === "asc" ? comparison : -comparison;
|
return direction === "asc" ? comparison : -comparison;
|
||||||
});
|
});
|
||||||
}, [data, debouncedSearchQuery, sortBy, selectedTagIds]);
|
}, [data, debouncedSearchQuery, sortBy]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -240,44 +208,29 @@ export const ShowProjects = () => {
|
|||||||
|
|
||||||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 min-w-48 max-sm:w-full">
|
||||||
<TagFilter
|
<ArrowUpDown className="size-4 text-muted-foreground" />
|
||||||
tags={
|
<Select value={sortBy} onValueChange={setSortBy}>
|
||||||
availableTags?.map((tag) => ({
|
<SelectTrigger className="w-full">
|
||||||
id: tag.tagId,
|
<SelectValue placeholder="Sort by..." />
|
||||||
name: tag.name,
|
</SelectTrigger>
|
||||||
color: tag.color || undefined,
|
<SelectContent>
|
||||||
})) || []
|
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
|
||||||
}
|
<SelectItem value="name-desc">Name (Z-A)</SelectItem>
|
||||||
selectedTags={selectedTagIds}
|
<SelectItem value="createdAt-desc">
|
||||||
onTagsChange={setSelectedTagIds}
|
Newest first
|
||||||
/>
|
</SelectItem>
|
||||||
<div className="flex items-center gap-2 min-w-48 max-sm:w-full">
|
<SelectItem value="createdAt-asc">
|
||||||
<ArrowUpDown className="size-4 text-muted-foreground" />
|
Oldest first
|
||||||
<Select value={sortBy} onValueChange={setSortBy}>
|
</SelectItem>
|
||||||
<SelectTrigger className="w-full">
|
<SelectItem value="services-desc">
|
||||||
<SelectValue placeholder="Sort by..." />
|
Most services
|
||||||
</SelectTrigger>
|
</SelectItem>
|
||||||
<SelectContent>
|
<SelectItem value="services-asc">
|
||||||
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
|
Least services
|
||||||
<SelectItem value="name-desc">
|
</SelectItem>
|
||||||
Name (Z-A)
|
</SelectContent>
|
||||||
</SelectItem>
|
</Select>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
{filteredProjects?.length === 0 && (
|
{filteredProjects?.length === 0 && (
|
||||||
@@ -294,27 +247,26 @@ export const ShowProjects = () => {
|
|||||||
.map(
|
.map(
|
||||||
(env) =>
|
(env) =>
|
||||||
env.applications.length === 0 &&
|
env.applications.length === 0 &&
|
||||||
env.compose.length === 0 &&
|
|
||||||
env.libsql.length === 0 &&
|
|
||||||
env.mariadb.length === 0 &&
|
env.mariadb.length === 0 &&
|
||||||
env.mongo.length === 0 &&
|
env.mongo.length === 0 &&
|
||||||
env.mysql.length === 0 &&
|
env.mysql.length === 0 &&
|
||||||
env.postgres.length === 0 &&
|
env.postgres.length === 0 &&
|
||||||
env.redis.length === 0,
|
env.redis.length === 0 &&
|
||||||
|
env.applications.length === 0 &&
|
||||||
|
env.compose.length === 0,
|
||||||
)
|
)
|
||||||
.every(Boolean);
|
.every(Boolean);
|
||||||
|
|
||||||
const totalServices = project?.environments
|
const totalServices = project?.environments
|
||||||
.map(
|
.map(
|
||||||
(env) =>
|
(env) =>
|
||||||
env.applications.length +
|
|
||||||
env.compose.length +
|
|
||||||
env.libsql.length +
|
|
||||||
env.mariadb.length +
|
env.mariadb.length +
|
||||||
env.mongo.length +
|
env.mongo.length +
|
||||||
env.mysql.length +
|
env.mysql.length +
|
||||||
env.postgres.length +
|
env.postgres.length +
|
||||||
env.redis.length,
|
env.redis.length +
|
||||||
|
env.applications.length +
|
||||||
|
env.compose.length,
|
||||||
)
|
)
|
||||||
.reduce((acc, curr) => acc + curr, 0);
|
.reduce((acc, curr) => acc + curr, 0);
|
||||||
|
|
||||||
@@ -357,19 +309,6 @@ export const ShowProjects = () => {
|
|||||||
{project.description}
|
{project.description}
|
||||||
</span>
|
</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 && (
|
{hasNoEnvironments && (
|
||||||
<div className="flex flex-row gap-2 items-center rounded-lg bg-yellow-50 p-2 mt-2 dark:bg-yellow-950">
|
<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" />
|
<AlertTriangle className="size-4 text-yellow-600 dark:text-yellow-400 shrink-0" />
|
||||||
@@ -490,7 +429,7 @@ export const ShowProjects = () => {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardFooter className="pt-4">
|
<CardFooter className="pt-4">
|
||||||
<div className="space-y-1 text-xs flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4">
|
<div className="space-y-1 text-sm flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4">
|
||||||
<DateTooltip date={project.createdAt}>
|
<DateTooltip date={project.createdAt}>
|
||||||
Created
|
Created
|
||||||
</DateTooltip>
|
</DateTooltip>
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||||
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
redisId: string;
|
redisId: string;
|
||||||
}
|
}
|
||||||
export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
|
export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
|
||||||
const { data } = api.redis.one.useQuery({ redisId });
|
const { data } = api.redis.one.useQuery({ redisId });
|
||||||
const utils = api.useUtils();
|
|
||||||
const { mutateAsync: changePassword } =
|
|
||||||
api.redis.changePassword.useMutation();
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
@@ -29,21 +24,11 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Password</Label>
|
<Label>Password</Label>
|
||||||
<div className="flex flex-row gap-2 items-center">
|
<div className="flex flex-row gap-4">
|
||||||
<ToggleVisibilityInput
|
<ToggleVisibilityInput
|
||||||
value={data?.databasePassword}
|
value={data?.databasePassword}
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
<UpdateDatabasePassword
|
|
||||||
onUpdatePassword={async (newPassword) => {
|
|
||||||
await changePassword({
|
|
||||||
redisId,
|
|
||||||
password: newPassword,
|
|
||||||
});
|
|
||||||
toast.success("Password updated successfully");
|
|
||||||
utils.redis.one.invalidate({ redisId });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const SearchCommand = () => {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const down = (e: KeyboardEvent) => {
|
const down = (e: KeyboardEvent) => {
|
||||||
if (e.code === "KeyJ" && (e.metaKey || e.ctrlKey)) {
|
if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setOpen((open) => !open);
|
setOpen((open) => !open);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
MinusIcon,
|
MinusIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
ShieldCheck,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
@@ -92,10 +91,7 @@ export const ShowBilling = () => {
|
|||||||
api.stripe.upgradeSubscription.useMutation();
|
api.stripe.upgradeSubscription.useMutation();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
|
const [serverQuantity, setServerQuantity] = useState(3);
|
||||||
const [startupServerQuantity, setStartupServerQuantity] = useState(
|
|
||||||
STARTUP_SERVERS_INCLUDED,
|
|
||||||
);
|
|
||||||
const [isAnnual, setIsAnnual] = useState(false);
|
const [isAnnual, setIsAnnual] = useState(false);
|
||||||
const [upgradeTier, setUpgradeTier] = useState<"hobby" | "startup" | null>(
|
const [upgradeTier, setUpgradeTier] = useState<"hobby" | "startup" | null>(
|
||||||
null,
|
null,
|
||||||
@@ -115,12 +111,6 @@ export const ShowBilling = () => {
|
|||||||
productId: string,
|
productId: string,
|
||||||
) => {
|
) => {
|
||||||
const stripe = await stripePromise;
|
const stripe = await stripePromise;
|
||||||
const serverQuantity =
|
|
||||||
tier === "startup"
|
|
||||||
? startupServerQuantity
|
|
||||||
: tier === "hobby"
|
|
||||||
? hobbyServerQuantity
|
|
||||||
: hobbyServerQuantity;
|
|
||||||
if (data && data.subscriptions.length === 0) {
|
if (data && data.subscriptions.length === 0) {
|
||||||
createCheckoutSession({
|
createCheckoutSession({
|
||||||
tier,
|
tier,
|
||||||
@@ -142,7 +132,6 @@ export const ShowBilling = () => {
|
|||||||
return isAnnual ? interval === "year" : interval === "month";
|
return isAnnual ? interval === "year" : interval === "month";
|
||||||
});
|
});
|
||||||
|
|
||||||
const isEnterpriseCloud = admin?.user.isEnterpriseCloud ?? false;
|
|
||||||
const maxServers = admin?.user.serversQuantity ?? 1;
|
const maxServers = admin?.user.serversQuantity ?? 1;
|
||||||
const percentage = ((servers ?? 0) / maxServers) * 100;
|
const percentage = ((servers ?? 0) / maxServers) * 100;
|
||||||
const safePercentage = Math.min(percentage, 100);
|
const safePercentage = Math.min(percentage, 100);
|
||||||
@@ -184,7 +173,7 @@ export const ShowBilling = () => {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 w-full mt-6">
|
<div className="flex flex-col gap-4 w-full mt-6">
|
||||||
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
|
{admin?.user.stripeSubscriptionId && (
|
||||||
<div className="space-y-2 flex flex-col">
|
<div className="space-y-2 flex flex-col">
|
||||||
<h3 className="text-lg font-medium">Servers Plan</h3>
|
<h3 className="text-lg font-medium">Servers Plan</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -205,36 +194,8 @@ export const ShowBilling = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isEnterpriseCloud && (
|
|
||||||
<div className="flex items-start gap-3 rounded-xl border border-primary/30 bg-primary/5 p-4 max-w-2xl">
|
|
||||||
<ShieldCheck className="h-6 w-6 text-primary shrink-0 mt-0.5" />
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<h3 className="text-base font-semibold text-foreground">
|
|
||||||
Enterprise Cloud Plan
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Your organization is on a managed Enterprise plan. Billing
|
|
||||||
is handled separately — contact your account manager for
|
|
||||||
any changes.
|
|
||||||
</p>
|
|
||||||
{admin?.user.stripeCustomerId && (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
className="w-fit mt-2"
|
|
||||||
onClick={async () => {
|
|
||||||
const session = await createCustomerPortalSession();
|
|
||||||
window.open(session.url);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Manage Subscription
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Upgrade: solo para usuarios en plan legacy con nuevos planes disponibles */}
|
{/* Upgrade: solo para usuarios en plan legacy con nuevos planes disponibles */}
|
||||||
{!isEnterpriseCloud &&
|
{useNewPricing &&
|
||||||
useNewPricing &&
|
|
||||||
data?.currentPlan === "legacy" &&
|
data?.currentPlan === "legacy" &&
|
||||||
data?.subscriptions?.length > 0 && (
|
data?.subscriptions?.length > 0 && (
|
||||||
<div className="rounded-xl border border-border bg-primary/5 p-4 space-y-4 max-w-2xl">
|
<div className="rounded-xl border border-border bg-primary/5 p-4 space-y-4 max-w-2xl">
|
||||||
@@ -424,8 +385,7 @@ export const ShowBilling = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Cambiar plan o cantidad de servidores (usuarios en Hobby o Startup; el portal no permite esto) */}
|
{/* Cambiar plan o cantidad de servidores (usuarios en Hobby o Startup; el portal no permite esto) */}
|
||||||
{!isEnterpriseCloud &&
|
{useNewPricing &&
|
||||||
useNewPricing &&
|
|
||||||
(data?.currentPlan === "hobby" ||
|
(data?.currentPlan === "hobby" ||
|
||||||
data?.currentPlan === "startup") &&
|
data?.currentPlan === "startup") &&
|
||||||
data?.subscriptions?.length > 0 && (
|
data?.subscriptions?.length > 0 && (
|
||||||
@@ -719,7 +679,7 @@ export const ShowBilling = () => {
|
|||||||
<p className="text-2xl font-semibold text-foreground">
|
<p className="text-2xl font-semibold text-foreground">
|
||||||
$
|
$
|
||||||
{calculatePriceHobby(
|
{calculatePriceHobby(
|
||||||
hobbyServerQuantity,
|
serverQuantity,
|
||||||
isAnnual,
|
isAnnual,
|
||||||
).toFixed(2)}
|
).toFixed(2)}
|
||||||
/{isAnnual ? "yr" : "mo"}
|
/{isAnnual ? "yr" : "mo"}
|
||||||
@@ -732,8 +692,7 @@ export const ShowBilling = () => {
|
|||||||
<p className="text-xs text-muted-foreground mt-2">
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
$
|
$
|
||||||
{(
|
{(
|
||||||
calculatePriceHobby(hobbyServerQuantity, true) /
|
calculatePriceHobby(serverQuantity, true) / 12
|
||||||
12
|
|
||||||
).toFixed(2)}
|
).toFixed(2)}
|
||||||
/mo
|
/mo
|
||||||
</p>
|
</p>
|
||||||
@@ -765,19 +724,19 @@ export const ShowBilling = () => {
|
|||||||
Servers:
|
Servers:
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
disabled={hobbyServerQuantity <= 1}
|
disabled={serverQuantity <= 1}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setHobbyServerQuantity((q) => Math.max(1, q - 1))
|
setServerQuantity((q) => Math.max(1, q - 1))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<MinusIcon className="h-4 w-4" />
|
<MinusIcon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={hobbyServerQuantity}
|
value={serverQuantity}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setHobbyServerQuantity(
|
setServerQuantity(
|
||||||
Math.max(
|
Math.max(
|
||||||
1,
|
1,
|
||||||
Number(
|
Number(
|
||||||
@@ -791,7 +750,7 @@ export const ShowBilling = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setHobbyServerQuantity((q) => q + 1)}
|
onClick={() => setServerQuantity((q) => q + 1)}
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -810,18 +769,17 @@ export const ShowBilling = () => {
|
|||||||
Manage Subscription
|
Manage Subscription
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!isEnterpriseCloud &&
|
{(data?.subscriptions?.length ?? 0) === 0 && (
|
||||||
(data?.subscriptions?.length ?? 0) === 0 && (
|
<Button
|
||||||
<Button
|
className="w-full"
|
||||||
className="w-full"
|
onClick={() =>
|
||||||
onClick={() =>
|
handleCheckout("hobby", data!.hobbyProductId!)
|
||||||
handleCheckout("hobby", data!.hobbyProductId!)
|
}
|
||||||
}
|
disabled={serverQuantity < 1}
|
||||||
disabled={hobbyServerQuantity < 1}
|
>
|
||||||
>
|
Get Started
|
||||||
Get Started
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -848,7 +806,7 @@ export const ShowBilling = () => {
|
|||||||
<p className="text-2xl font-semibold text-foreground">
|
<p className="text-2xl font-semibold text-foreground">
|
||||||
$
|
$
|
||||||
{calculatePriceStartup(
|
{calculatePriceStartup(
|
||||||
startupServerQuantity,
|
serverQuantity,
|
||||||
isAnnual,
|
isAnnual,
|
||||||
).toFixed(2)}
|
).toFixed(2)}
|
||||||
/{isAnnual ? "yr" : "mo"}
|
/{isAnnual ? "yr" : "mo"}
|
||||||
@@ -861,10 +819,7 @@ export const ShowBilling = () => {
|
|||||||
<p className="text-xs text-muted-foreground mt-2">
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
$
|
$
|
||||||
{(
|
{(
|
||||||
calculatePriceStartup(
|
calculatePriceStartup(serverQuantity, true) / 12
|
||||||
startupServerQuantity,
|
|
||||||
true,
|
|
||||||
) / 12
|
|
||||||
).toFixed(2)}
|
).toFixed(2)}
|
||||||
/mo
|
/mo
|
||||||
</p>
|
</p>
|
||||||
@@ -901,14 +856,13 @@ export const ShowBilling = () => {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
disabled={
|
disabled={
|
||||||
startupServerQuantity <=
|
serverQuantity <= STARTUP_SERVERS_INCLUDED
|
||||||
STARTUP_SERVERS_INCLUDED
|
|
||||||
}
|
}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setStartupServerQuantity((q) =>
|
setServerQuantity((q) =>
|
||||||
Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
|
Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -916,9 +870,9 @@ export const ShowBilling = () => {
|
|||||||
<MinusIcon className="h-4 w-4" />
|
<MinusIcon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={startupServerQuantity}
|
value={serverQuantity}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setStartupServerQuantity(
|
setServerQuantity(
|
||||||
Math.max(
|
Math.max(
|
||||||
STARTUP_SERVERS_INCLUDED,
|
STARTUP_SERVERS_INCLUDED,
|
||||||
Number(
|
Number(
|
||||||
@@ -933,9 +887,7 @@ export const ShowBilling = () => {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
onClick={() =>
|
onClick={() => setServerQuantity((q) => q + 1)}
|
||||||
setStartupServerQuantity((q) => q + 1)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -955,24 +907,22 @@ export const ShowBilling = () => {
|
|||||||
Manage Subscription
|
Manage Subscription
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!isEnterpriseCloud &&
|
{(data?.subscriptions?.length ?? 0) === 0 && (
|
||||||
(data?.subscriptions?.length ?? 0) === 0 && (
|
<Button
|
||||||
<Button
|
className="w-full"
|
||||||
className="w-full"
|
onClick={() =>
|
||||||
onClick={() =>
|
handleCheckout(
|
||||||
handleCheckout(
|
"startup",
|
||||||
"startup",
|
data!.startupProductId!,
|
||||||
data!.startupProductId!,
|
)
|
||||||
)
|
}
|
||||||
}
|
disabled={
|
||||||
disabled={
|
serverQuantity < STARTUP_SERVERS_INCLUDED
|
||||||
startupServerQuantity <
|
}
|
||||||
STARTUP_SERVERS_INCLUDED
|
>
|
||||||
}
|
Get Started
|
||||||
>
|
</Button>
|
||||||
Get Started
|
)}
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1059,7 +1009,7 @@ export const ShowBilling = () => {
|
|||||||
<p className="text-2xl font-semibold tracking-tight text-primary ">
|
<p className="text-2xl font-semibold tracking-tight text-primary ">
|
||||||
${" "}
|
${" "}
|
||||||
{calculatePrice(
|
{calculatePrice(
|
||||||
hobbyServerQuantity,
|
serverQuantity,
|
||||||
isAnnual,
|
isAnnual,
|
||||||
).toFixed(2)}{" "}
|
).toFixed(2)}{" "}
|
||||||
USD
|
USD
|
||||||
@@ -1068,10 +1018,7 @@ export const ShowBilling = () => {
|
|||||||
<p className="text-base font-semibold tracking-tight text-muted-foreground">
|
<p className="text-base font-semibold tracking-tight text-muted-foreground">
|
||||||
${" "}
|
${" "}
|
||||||
{(
|
{(
|
||||||
calculatePrice(
|
calculatePrice(serverQuantity, isAnnual) / 12
|
||||||
hobbyServerQuantity,
|
|
||||||
isAnnual,
|
|
||||||
) / 12
|
|
||||||
).toFixed(2)}{" "}
|
).toFixed(2)}{" "}
|
||||||
/ Month USD
|
/ Month USD
|
||||||
</p>
|
</p>
|
||||||
@@ -1079,10 +1026,9 @@ export const ShowBilling = () => {
|
|||||||
) : (
|
) : (
|
||||||
<p className="text-2xl font-semibold tracking-tight text-primary ">
|
<p className="text-2xl font-semibold tracking-tight text-primary ">
|
||||||
${" "}
|
${" "}
|
||||||
{calculatePrice(
|
{calculatePrice(serverQuantity, isAnnual).toFixed(
|
||||||
hobbyServerQuantity,
|
2,
|
||||||
isAnnual,
|
)}{" "}
|
||||||
).toFixed(2)}{" "}
|
|
||||||
USD
|
USD
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -1125,28 +1071,26 @@ export const ShowBilling = () => {
|
|||||||
<div className="flex flex-col gap-2 mt-4">
|
<div className="flex flex-col gap-2 mt-4">
|
||||||
<div className="flex items-center gap-2 justify-center">
|
<div className="flex items-center gap-2 justify-center">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{hobbyServerQuantity} Servers
|
{serverQuantity} Servers
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Button
|
<Button
|
||||||
disabled={hobbyServerQuantity <= 1}
|
disabled={serverQuantity <= 1}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (hobbyServerQuantity <= 1) return;
|
if (serverQuantity <= 1) return;
|
||||||
|
|
||||||
setHobbyServerQuantity(
|
setServerQuantity(serverQuantity - 1);
|
||||||
hobbyServerQuantity - 1,
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MinusIcon className="h-4 w-4" />
|
<MinusIcon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={hobbyServerQuantity}
|
value={serverQuantity}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setHobbyServerQuantity(
|
setServerQuantity(
|
||||||
e.target.value as unknown as number,
|
e.target.value as unknown as number,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -1155,9 +1099,7 @@ export const ShowBilling = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setHobbyServerQuantity(
|
setServerQuantity(serverQuantity + 1);
|
||||||
hobbyServerQuantity + 1,
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-4 w-4" />
|
||||||
@@ -1177,18 +1119,17 @@ export const ShowBilling = () => {
|
|||||||
Manage Subscription
|
Manage Subscription
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!isEnterpriseCloud &&
|
{(data?.subscriptions?.length ?? 0) === 0 && (
|
||||||
(data?.subscriptions?.length ?? 0) === 0 && (
|
<Button
|
||||||
<Button
|
className="w-full"
|
||||||
className="w-full"
|
onClick={async () => {
|
||||||
onClick={async () => {
|
handleCheckout("legacy", product.id);
|
||||||
handleCheckout("legacy", product.id);
|
}}
|
||||||
}}
|
disabled={serverQuantity < 1}
|
||||||
disabled={hobbyServerQuantity < 1}
|
>
|
||||||
>
|
Subscribe
|
||||||
Subscribe
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { HelpCircle, PlusIcon, SquarePen } from "lucide-react";
|
import { HelpCircle, PlusIcon } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -47,157 +47,108 @@ const certificateDataHolder =
|
|||||||
const privateKeyDataHolder =
|
const privateKeyDataHolder =
|
||||||
"-----BEGIN PRIVATE KEY-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n-----END PRIVATE KEY-----";
|
"-----BEGIN PRIVATE KEY-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n-----END PRIVATE KEY-----";
|
||||||
|
|
||||||
const handleCertificateSchema = z.object({
|
const addCertificate = z.object({
|
||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
certificateData: z.string().min(1, "Certificate data is required"),
|
certificateData: z.string().min(1, "Certificate data is required"),
|
||||||
privateKey: z.string().min(1, "Private key is required"),
|
privateKey: z.string().min(1, "Private key is required"),
|
||||||
|
autoRenew: z.boolean().optional(),
|
||||||
serverId: z.string().optional(),
|
serverId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type HandleCertificateForm = z.infer<typeof handleCertificateSchema>;
|
type AddCertificate = z.infer<typeof addCertificate>;
|
||||||
|
|
||||||
interface Props {
|
export const AddCertificate = () => {
|
||||||
certificateId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HandleCertificate = ({ certificateId }: Props) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { mutateAsync, isError, error, isPending } =
|
||||||
|
api.certificates.create.useMutation();
|
||||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||||
const hasServers = servers && servers.length > 0;
|
const hasServers = servers && servers.length > 0;
|
||||||
const shouldShowServerDropdown = hasServers && !certificateId; // Hide on edit
|
// Show dropdown logic based on cloud environment
|
||||||
|
// Cloud: show only if there are remote servers (no Dokploy option)
|
||||||
|
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
|
||||||
|
const shouldShowServerDropdown = hasServers;
|
||||||
|
|
||||||
const { data: existingCert, refetch } = api.certificates.one.useQuery(
|
const form = useForm<AddCertificate>({
|
||||||
{ certificateId: certificateId || "" },
|
|
||||||
{ enabled: !!certificateId },
|
|
||||||
);
|
|
||||||
|
|
||||||
const createMutation = api.certificates.create.useMutation();
|
|
||||||
const updateMutation = api.certificates.update.useMutation();
|
|
||||||
const mutation = certificateId ? updateMutation : createMutation;
|
|
||||||
const { mutateAsync, isError, error, isPending } = mutation;
|
|
||||||
|
|
||||||
const form = useForm<HandleCertificateForm>({
|
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
certificateData: "",
|
certificateData: "",
|
||||||
privateKey: "",
|
privateKey: "",
|
||||||
|
autoRenew: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(handleCertificateSchema),
|
resolver: zodResolver(addCertificate),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (existingCert) {
|
form.reset();
|
||||||
form.reset({
|
}, [form, form.formState.isSubmitSuccessful, form.reset]);
|
||||||
name: existingCert.name,
|
|
||||||
certificateData: existingCert.certificateData,
|
|
||||||
privateKey: existingCert.privateKey,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
form.reset({
|
|
||||||
name: "",
|
|
||||||
certificateData: "",
|
|
||||||
privateKey: "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [existingCert, form, open]);
|
|
||||||
|
|
||||||
const onSubmit = async (data: HandleCertificateForm) => {
|
const onSubmit = async (data: AddCertificate) => {
|
||||||
const basePayload = {
|
await mutateAsync({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
certificateData: data.certificateData,
|
certificateData: data.certificateData,
|
||||||
privateKey: data.privateKey,
|
privateKey: data.privateKey,
|
||||||
};
|
autoRenew: data.autoRenew,
|
||||||
|
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
|
||||||
const promise = certificateId
|
organizationId: "",
|
||||||
? updateMutation.mutateAsync({
|
})
|
||||||
certificateId,
|
|
||||||
...basePayload,
|
|
||||||
})
|
|
||||||
: createMutation.mutateAsync({
|
|
||||||
...basePayload,
|
|
||||||
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
|
|
||||||
organizationId: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
await promise
|
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success(
|
toast.success("Certificate Created");
|
||||||
certificateId ? "Certificate Updated" : "Certificate Created",
|
|
||||||
);
|
|
||||||
await utils.certificates.all.invalidate();
|
await utils.certificates.all.invalidate();
|
||||||
if (certificateId) {
|
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error(
|
toast.error("Error creating the Certificate");
|
||||||
certificateId
|
|
||||||
? "Error updating the Certificate"
|
|
||||||
: "Error creating the Certificate",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger className="" asChild>
|
||||||
{certificateId ? (
|
<Button>
|
||||||
<Button
|
{" "}
|
||||||
variant="ghost"
|
<PlusIcon className="h-4 w-4" />
|
||||||
size="icon"
|
Add Certificate
|
||||||
className="group hover:bg-blue-500/10"
|
</Button>
|
||||||
>
|
|
||||||
<SquarePen className="size-3.5 text-primary group-hover:text-blue-500" />
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button>
|
|
||||||
<PlusIcon className="h-4 w-4" />
|
|
||||||
Add Certificate
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-2xl">
|
<DialogContent className="sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>Add New Certificate</DialogTitle>
|
||||||
{certificateId ? "Update" : "Add New"} Certificate
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{certificateId
|
Upload or generate a certificate to secure your application
|
||||||
? "Modify the certificate details"
|
|
||||||
: "Upload or generate a certificate to secure your application"}
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
id="hook-form-handle-certificate"
|
id="hook-form-add-certificate"
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="grid w-full gap-4"
|
className="grid w-full gap-4 "
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
<FormItem>
|
return (
|
||||||
<FormLabel>Certificate Name</FormLabel>
|
<FormItem>
|
||||||
<FormControl>
|
<FormLabel>Certificate Name</FormLabel>
|
||||||
<Input placeholder="My Certificate" {...field} />
|
<FormControl>
|
||||||
</FormControl>
|
<Input placeholder={"My Certificate"} {...field} />
|
||||||
<FormMessage />
|
</FormControl>
|
||||||
</FormItem>
|
<FormMessage />
|
||||||
)}
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="certificateData"
|
name="certificateData"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Certificate Data</FormLabel>
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Certificate Data</FormLabel>
|
||||||
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
className="h-32"
|
className="h-32"
|
||||||
@@ -214,7 +165,9 @@ export const HandleCertificate = ({ certificateId }: Props) => {
|
|||||||
name="privateKey"
|
name="privateKey"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Private Key</FormLabel>
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Private Key</FormLabel>
|
||||||
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
className="h-32"
|
className="h-32"
|
||||||
@@ -295,10 +248,10 @@ export const HandleCertificate = ({ certificateId }: Props) => {
|
|||||||
<DialogFooter className="flex w-full flex-row !justify-end">
|
<DialogFooter className="flex w-full flex-row !justify-end">
|
||||||
<Button
|
<Button
|
||||||
isLoading={isPending}
|
isLoading={isPending}
|
||||||
form="hook-form-handle-certificate"
|
form="hook-form-add-certificate"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
{certificateId ? "Update" : "Create"}
|
Create
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -1,14 +1,4 @@
|
|||||||
import {
|
import { AlertCircle, Link, Loader2, ShieldCheck, Trash2 } from "lucide-react";
|
||||||
AlertCircle,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Link,
|
|
||||||
Loader2,
|
|
||||||
Server,
|
|
||||||
ShieldCheck,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
@@ -21,20 +11,14 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { HandleCertificate } from "./handle-certificate";
|
import { AddCertificate } from "./add-certificate";
|
||||||
import {
|
import { getCertificateChainInfo, getExpirationStatus } from "./utils";
|
||||||
extractLeafCommonName,
|
|
||||||
getCertificateChainExpirationDetails,
|
|
||||||
getCertificateChainInfo,
|
|
||||||
getExpirationStatus,
|
|
||||||
} from "./utils";
|
|
||||||
|
|
||||||
export const ShowCertificates = () => {
|
export const ShowCertificates = () => {
|
||||||
const { mutateAsync, isPending: isRemoving } =
|
const { mutateAsync, isPending: isRemoving } =
|
||||||
api.certificates.remove.useMutation();
|
api.certificates.remove.useMutation();
|
||||||
const { data, isPending, refetch } = api.certificates.all.useQuery();
|
const { data, isPending, refetch } = api.certificates.all.useQuery();
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
const [expandedChains, setExpandedChains] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@@ -70,7 +54,7 @@ export const ShowCertificates = () => {
|
|||||||
<span className="text-base text-muted-foreground text-center">
|
<span className="text-base text-muted-foreground text-center">
|
||||||
You don't have any certificates created
|
You don't have any certificates created
|
||||||
</span>
|
</span>
|
||||||
{permissions?.certificate.create && <HandleCertificate />}
|
{permissions?.certificate.create && <AddCertificate />}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||||
@@ -82,30 +66,6 @@ export const ShowCertificates = () => {
|
|||||||
const chainInfo = getCertificateChainInfo(
|
const chainInfo = getCertificateChainInfo(
|
||||||
certificate.certificateData,
|
certificate.certificateData,
|
||||||
);
|
);
|
||||||
const commonName = extractLeafCommonName(
|
|
||||||
certificate.certificateData,
|
|
||||||
);
|
|
||||||
const chainDetails = chainInfo.isChain
|
|
||||||
? getCertificateChainExpirationDetails(
|
|
||||||
certificate.certificateData,
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const isExpanded = expandedChains.has(
|
|
||||||
certificate.certificateId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleChain = () => {
|
|
||||||
setExpandedChains((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(certificate.certificateId)) {
|
|
||||||
next.delete(certificate.certificateId);
|
|
||||||
} else {
|
|
||||||
next.add(certificate.certificateId);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={certificate.certificateId}
|
key={certificate.certificateId}
|
||||||
@@ -117,58 +77,12 @@ export const ShowCertificates = () => {
|
|||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{index + 1}. {certificate.name}
|
{index + 1}. {certificate.name}
|
||||||
</span>
|
</span>
|
||||||
{commonName && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
CN: {commonName}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
|
||||||
<Server className="size-3" />
|
|
||||||
{certificate.server
|
|
||||||
? `${certificate.server.name} (${certificate.server.ipAddress})`
|
|
||||||
: "Dokploy (Local)"}
|
|
||||||
</span>
|
|
||||||
{chainInfo.isChain && (
|
{chainInfo.isChain && (
|
||||||
<div className="flex flex-col gap-1.5 mt-1">
|
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50">
|
||||||
<button
|
<Link className="size-3 text-muted-foreground" />
|
||||||
type="button"
|
<span className="text-xs text-muted-foreground">
|
||||||
onClick={toggleChain}
|
Chain ({chainInfo.count})
|
||||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50 w-fit hover:bg-muted transition-colors"
|
</span>
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronDown className="size-3 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="size-3 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<Link className="size-3 text-muted-foreground" />
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Chain ({chainInfo.count} certificates)
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="flex flex-col gap-3 pl-2 border-l-2 border-muted">
|
|
||||||
{chainDetails?.map((cert) => (
|
|
||||||
<div
|
|
||||||
key={cert.index}
|
|
||||||
className="flex flex-col gap-1 p-2 rounded-md bg-muted/30"
|
|
||||||
>
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
|
||||||
{cert.label}
|
|
||||||
</span>
|
|
||||||
{cert.commonName && (
|
|
||||||
<span className="text-xs text-muted-foreground/80">
|
|
||||||
CN: {cert.commonName}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={`text-xs ${cert.className}`}
|
|
||||||
>
|
|
||||||
{cert.message}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
@@ -188,14 +102,8 @@ export const ShowCertificates = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row gap-1">
|
{permissions?.certificate.delete && (
|
||||||
{permissions?.certificate.update && (
|
<div className="flex flex-row gap-1">
|
||||||
<HandleCertificate
|
|
||||||
certificateId={certificate.certificateId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{permissions?.certificate.delete && (
|
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Delete Certificate"
|
title="Delete Certificate"
|
||||||
description="Are you sure you want to delete this certificate?"
|
description="Are you sure you want to delete this certificate?"
|
||||||
@@ -221,14 +129,14 @@ export const ShowCertificates = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="group hover:bg-red-500/10"
|
className="group hover:bg-red-500/10 "
|
||||||
isLoading={isRemoving}
|
isLoading={isRemoving}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -237,7 +145,7 @@ export const ShowCertificates = () => {
|
|||||||
|
|
||||||
{permissions?.certificate.create && (
|
{permissions?.certificate.create && (
|
||||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||||
<HandleCertificate />
|
<AddCertificate />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
|
||||||
// Split certificate chain into individual certificates
|
|
||||||
export const splitCertificateChain = (certData: string): string[] => {
|
|
||||||
const certRegex =
|
|
||||||
/(-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----)/g;
|
|
||||||
const matches = certData.match(certRegex);
|
|
||||||
return matches || [];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const extractExpirationDate = (certData: string): Date | null => {
|
export const extractExpirationDate = (certData: string): Date | null => {
|
||||||
try {
|
try {
|
||||||
// Decode PEM base64 to DER binary
|
// Decode PEM base64 to DER binary
|
||||||
@@ -22,13 +14,13 @@ export const extractExpirationDate = (certData: string): Date | null => {
|
|||||||
|
|
||||||
// Helper: read ASN.1 length field
|
// Helper: read ASN.1 length field
|
||||||
function readLength(pos: number): { length: number; offset: number } {
|
function readLength(pos: number): { length: number; offset: number } {
|
||||||
// biome-ignore lint/style/noParameterAssign: this is for dynamic length calculation
|
// biome-ignore lint/style/noParameterAssign: <explanation>
|
||||||
let len = der[pos++];
|
let len = der[pos++];
|
||||||
if (len & 0x80) {
|
if (len & 0x80) {
|
||||||
const bytes = len & 0x7f;
|
const bytes = len & 0x7f;
|
||||||
len = 0;
|
len = 0;
|
||||||
for (let i = 0; i < bytes; i++) {
|
for (let i = 0; i < bytes; i++) {
|
||||||
// biome-ignore lint/style/noParameterAssign: this is for dynamic length calculation
|
// biome-ignore lint/style/noParameterAssign: <explanation>
|
||||||
len = (len << 8) + der[pos++];
|
len = (len << 8) + der[pos++];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,11 +28,11 @@ export const extractExpirationDate = (certData: string): Date | null => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Skip the outer certificate sequence
|
// Skip the outer certificate sequence
|
||||||
if (der[offset++] !== 0x30) return null;
|
if (der[offset++] !== 0x30) throw new Error("Expected sequence");
|
||||||
({ offset } = readLength(offset));
|
({ offset } = readLength(offset));
|
||||||
|
|
||||||
// Skip tbsCertificate sequence
|
// Skip tbsCertificate sequence
|
||||||
if (der[offset++] !== 0x30) return null;
|
if (der[offset++] !== 0x30) throw new Error("Expected tbsCertificate");
|
||||||
({ offset } = readLength(offset));
|
({ offset } = readLength(offset));
|
||||||
|
|
||||||
// Check for optional version field (context-specific tag [0])
|
// Check for optional version field (context-specific tag [0])
|
||||||
@@ -52,14 +44,15 @@ export const extractExpirationDate = (certData: string): Date | null => {
|
|||||||
|
|
||||||
// Skip serialNumber, signature, issuer
|
// Skip serialNumber, signature, issuer
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
if (der[offset] !== 0x30 && der[offset] !== 0x02) return null;
|
if (der[offset] !== 0x30 && der[offset] !== 0x02)
|
||||||
|
throw new Error("Unexpected structure");
|
||||||
offset++;
|
offset++;
|
||||||
const fieldLen = readLength(offset);
|
const fieldLen = readLength(offset);
|
||||||
offset = fieldLen.offset + fieldLen.length;
|
offset = fieldLen.offset + fieldLen.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validity sequence (notBefore and notAfter)
|
// Validity sequence (notBefore and notAfter)
|
||||||
if (der[offset++] !== 0x30) return null;
|
if (der[offset++] !== 0x30) throw new Error("Expected validity sequence");
|
||||||
const validityLen = readLength(offset);
|
const validityLen = readLength(offset);
|
||||||
offset = validityLen.offset;
|
offset = validityLen.offset;
|
||||||
|
|
||||||
@@ -101,156 +94,8 @@ export const extractExpirationDate = (certData: string): Date | null => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const extractCommonName = (certData: string): string | null => {
|
|
||||||
try {
|
|
||||||
// Decode PEM base64 to DER binary
|
|
||||||
const b64 = certData.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "");
|
|
||||||
const binStr = atob(b64);
|
|
||||||
const der = new Uint8Array(binStr.length);
|
|
||||||
for (let i = 0; i < binStr.length; i++) {
|
|
||||||
der[i] = binStr.charCodeAt(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
// Helper: read ASN.1 length field
|
|
||||||
function readLength(pos: number): { length: number; offset: number } {
|
|
||||||
// biome-ignore lint/style/noParameterAssign: <explanation>
|
|
||||||
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>
|
|
||||||
len = (len << 8) + der[pos++];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { length: len, offset: pos };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: skip a field
|
|
||||||
function skipField(pos: number): number {
|
|
||||||
// biome-ignore lint/style/noParameterAssign: <explanation>
|
|
||||||
pos++;
|
|
||||||
const fieldLen = readLength(pos);
|
|
||||||
return fieldLen.offset + fieldLen.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip the outer certificate sequence
|
|
||||||
if (der[offset++] !== 0x30) return null;
|
|
||||||
({ offset } = readLength(offset));
|
|
||||||
|
|
||||||
// Skip tbsCertificate sequence
|
|
||||||
if (der[offset++] !== 0x30) return null;
|
|
||||||
({ offset } = readLength(offset));
|
|
||||||
|
|
||||||
// Check for optional version field (context-specific tag [0])
|
|
||||||
if (der[offset] === 0xa0) {
|
|
||||||
offset++;
|
|
||||||
const versionLen = readLength(offset);
|
|
||||||
offset = versionLen.offset + versionLen.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip serialNumber
|
|
||||||
offset = skipField(offset);
|
|
||||||
|
|
||||||
// Skip signature
|
|
||||||
offset = skipField(offset);
|
|
||||||
|
|
||||||
// Skip issuer
|
|
||||||
offset = skipField(offset);
|
|
||||||
|
|
||||||
// Skip validity
|
|
||||||
offset = skipField(offset);
|
|
||||||
|
|
||||||
// Subject sequence - where we find the CN
|
|
||||||
if (der[offset++] !== 0x30) return null;
|
|
||||||
const subjectLen = readLength(offset);
|
|
||||||
const subjectEnd = subjectLen.offset + subjectLen.length;
|
|
||||||
offset = subjectLen.offset;
|
|
||||||
|
|
||||||
// Parse subject RDNs looking for CN (OID 2.5.4.3)
|
|
||||||
while (offset < subjectEnd) {
|
|
||||||
if (der[offset++] !== 0x31) continue; // SET
|
|
||||||
const setLen = readLength(offset);
|
|
||||||
offset = setLen.offset;
|
|
||||||
|
|
||||||
if (der[offset++] !== 0x30) continue; // SEQUENCE
|
|
||||||
const seqLen = readLength(offset);
|
|
||||||
offset = seqLen.offset;
|
|
||||||
|
|
||||||
if (der[offset++] !== 0x06) continue; // OID
|
|
||||||
const oidLen = readLength(offset);
|
|
||||||
offset = oidLen.offset;
|
|
||||||
|
|
||||||
// Check if OID is 2.5.4.3 (commonName)
|
|
||||||
const oid = Array.from(der.slice(offset, offset + oidLen.length));
|
|
||||||
offset += oidLen.length;
|
|
||||||
|
|
||||||
// OID 2.5.4.3 in DER: [0x55, 0x04, 0x03]
|
|
||||||
if (
|
|
||||||
oid.length === 3 &&
|
|
||||||
oid[0] === 0x55 &&
|
|
||||||
oid[1] === 0x04 &&
|
|
||||||
oid[2] === 0x03
|
|
||||||
) {
|
|
||||||
// Next should be the string value
|
|
||||||
const strType = der[offset++];
|
|
||||||
const strLen = readLength(offset);
|
|
||||||
const cnBytes = der.slice(strLen.offset, strLen.offset + strLen.length);
|
|
||||||
return new TextDecoder().decode(cnBytes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error parsing certificate CN:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extract the Common Name from the first (leaf) certificate in a chain
|
|
||||||
export const extractLeafCommonName = (certData: string): string | null => {
|
|
||||||
const certs = splitCertificateChain(certData);
|
|
||||||
if (certs.length === 0) return null;
|
|
||||||
return extractCommonName(certs[0]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extract expiration dates from all certificates in a chain
|
|
||||||
export const extractAllExpirationDates = (
|
|
||||||
certData: string,
|
|
||||||
): Array<{
|
|
||||||
cert: string;
|
|
||||||
index: number;
|
|
||||||
expirationDate: Date | null;
|
|
||||||
commonName: string | null;
|
|
||||||
}> => {
|
|
||||||
const certs = splitCertificateChain(certData);
|
|
||||||
return certs.map((cert, index) => ({
|
|
||||||
cert,
|
|
||||||
index,
|
|
||||||
expirationDate: extractExpirationDate(cert),
|
|
||||||
commonName: extractCommonName(cert),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get the earliest expiration date from a certificate chain
|
|
||||||
export const getEarliestExpirationDate = (certData: string): Date | null => {
|
|
||||||
const expirationDates = extractAllExpirationDates(certData);
|
|
||||||
const validDates = expirationDates
|
|
||||||
.filter((item) => item.expirationDate !== null)
|
|
||||||
.map((item) => item.expirationDate as Date);
|
|
||||||
|
|
||||||
if (validDates.length === 0) return null;
|
|
||||||
|
|
||||||
return new Date(Math.min(...validDates.map((date) => date.getTime())));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getExpirationStatus = (certData: string) => {
|
export const getExpirationStatus = (certData: string) => {
|
||||||
const chainInfo = getCertificateChainInfo(certData);
|
const expirationDate = extractExpirationDate(certData);
|
||||||
const expirationDate = chainInfo.isChain
|
|
||||||
? getEarliestExpirationDate(certData)
|
|
||||||
: extractExpirationDate(certData);
|
|
||||||
|
|
||||||
if (!expirationDate)
|
if (!expirationDate)
|
||||||
return {
|
return {
|
||||||
@@ -308,67 +153,3 @@ export const getCertificateChainInfo = (certData: string) => {
|
|||||||
count: 1,
|
count: 1,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get detailed expiration information for all certificates in a chain
|
|
||||||
export const getCertificateChainExpirationDetails = (certData: string) => {
|
|
||||||
const allExpirations = extractAllExpirationDates(certData);
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
return allExpirations.map(({ index, expirationDate, commonName }) => {
|
|
||||||
if (!expirationDate) {
|
|
||||||
return {
|
|
||||||
index,
|
|
||||||
label: `Certificate ${index + 1}`,
|
|
||||||
commonName,
|
|
||||||
status: "unknown" as const,
|
|
||||||
className: "text-muted-foreground",
|
|
||||||
message: "Could not determine expiration",
|
|
||||||
expirationDate: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const daysUntilExpiration = Math.ceil(
|
|
||||||
(expirationDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
|
||||||
);
|
|
||||||
|
|
||||||
let status: "expired" | "warning" | "valid";
|
|
||||||
let className: string;
|
|
||||||
let message: string;
|
|
||||||
|
|
||||||
if (daysUntilExpiration < 0) {
|
|
||||||
status = "expired";
|
|
||||||
className = "text-red-500";
|
|
||||||
message = `Expired on ${expirationDate.toLocaleDateString([], {
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
})}`;
|
|
||||||
} else if (daysUntilExpiration <= 30) {
|
|
||||||
status = "warning";
|
|
||||||
className = "text-yellow-500";
|
|
||||||
message = `Expires in ${daysUntilExpiration} days`;
|
|
||||||
} else {
|
|
||||||
status = "valid";
|
|
||||||
className = "text-muted-foreground";
|
|
||||||
message = `Expires ${expirationDate.toLocaleDateString([], {
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
})}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
index,
|
|
||||||
label:
|
|
||||||
index === 0
|
|
||||||
? `Certificate ${index + 1} (Leaf)`
|
|
||||||
: `Certificate ${index + 1}`,
|
|
||||||
commonName,
|
|
||||||
status,
|
|
||||||
className,
|
|
||||||
message,
|
|
||||||
expirationDate,
|
|
||||||
daysUntilExpiration,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import {
|
|
||||||
ADDITIONAL_FLAG_ERROR,
|
|
||||||
ADDITIONAL_FLAG_REGEX,
|
|
||||||
} from "@dokploy/server/db/validations/destination";
|
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { PenBoxIcon, PlusIcon, Trash2 } from "lucide-react";
|
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useFieldArray, useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
@@ -50,16 +46,6 @@ const addDestination = z.object({
|
|||||||
region: z.string(),
|
region: z.string(),
|
||||||
endpoint: z.string().min(1, "Endpoint is required"),
|
endpoint: z.string().min(1, "Endpoint is required"),
|
||||||
serverId: z.string().optional(),
|
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>;
|
type AddDestination = z.infer<typeof addDestination>;
|
||||||
@@ -103,16 +89,9 @@ export const HandleDestinations = ({ destinationId }: Props) => {
|
|||||||
region: "",
|
region: "",
|
||||||
secretAccessKey: "",
|
secretAccessKey: "",
|
||||||
endpoint: "",
|
endpoint: "",
|
||||||
additionalFlags: [],
|
|
||||||
},
|
},
|
||||||
resolver: zodResolver(addDestination),
|
resolver: zodResolver(addDestination),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({
|
|
||||||
control: form.control,
|
|
||||||
name: "additionalFlags",
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (destination) {
|
if (destination) {
|
||||||
form.reset({
|
form.reset({
|
||||||
@@ -123,8 +102,6 @@ export const HandleDestinations = ({ destinationId }: Props) => {
|
|||||||
bucket: destination.bucket,
|
bucket: destination.bucket,
|
||||||
region: destination.region,
|
region: destination.region,
|
||||||
endpoint: destination.endpoint,
|
endpoint: destination.endpoint,
|
||||||
additionalFlags:
|
|
||||||
destination.additionalFlags?.map((f) => ({ value: f })) ?? [],
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
form.reset();
|
form.reset();
|
||||||
@@ -141,7 +118,6 @@ export const HandleDestinations = ({ destinationId }: Props) => {
|
|||||||
region: data.region,
|
region: data.region,
|
||||||
secretAccessKey: data.secretAccessKey,
|
secretAccessKey: data.secretAccessKey,
|
||||||
destinationId: destinationId || "",
|
destinationId: destinationId || "",
|
||||||
additionalFlags: data.additionalFlags?.map((f) => f.value) ?? [],
|
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success(`Destination ${destinationId ? "Updated" : "Created"}`);
|
toast.success(`Destination ${destinationId ? "Updated" : "Created"}`);
|
||||||
@@ -151,12 +127,9 @@ export const HandleDestinations = ({ destinationId }: Props) => {
|
|||||||
}
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch(() => {
|
||||||
toast.error(
|
toast.error(
|
||||||
`Error ${destinationId ? "Updating" : "Creating"} the Destination`,
|
`Error ${destinationId ? "Updating" : "Creating"} the Destination`,
|
||||||
{
|
|
||||||
description: e.message,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -168,7 +141,6 @@ export const HandleDestinations = ({ destinationId }: Props) => {
|
|||||||
"secretAccessKey",
|
"secretAccessKey",
|
||||||
"bucket",
|
"bucket",
|
||||||
"endpoint",
|
"endpoint",
|
||||||
"additionalFlags",
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
@@ -207,8 +179,6 @@ export const HandleDestinations = ({ destinationId }: Props) => {
|
|||||||
region,
|
region,
|
||||||
secretAccessKey: secretKey,
|
secretAccessKey: secretKey,
|
||||||
serverId,
|
serverId,
|
||||||
additionalFlags:
|
|
||||||
form.getValues("additionalFlags")?.map((f) => f.value) ?? [],
|
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("Connection Success");
|
toast.success("Connection Success");
|
||||||
@@ -388,48 +358,6 @@ export const HandleDestinations = ({ destinationId }: Props) => {
|
|||||||
</FormItem>
|
</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>
|
</form>
|
||||||
|
|
||||||
<DialogFooter
|
<DialogFooter
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ export const AddGitlabProvider = () => {
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="For organization/group access use the slug name of the group eg: my-org"
|
placeholder="For organization/group access use the slugish name of the group eg: my-org"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="For organization/group access use the slug name of the group eg: my-org"
|
placeholder="For organization/group access use the slugish name of the group eg: my-org"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
ImportIcon,
|
ImportIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
Trash2,
|
Trash2,
|
||||||
Users,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -25,13 +24,6 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { useUrl } from "@/utils/hooks/use-url";
|
import { useUrl } from "@/utils/hooks/use-url";
|
||||||
import { AddBitbucketProvider } from "./bitbucket/add-bitbucket-provider";
|
import { AddBitbucketProvider } from "./bitbucket/add-bitbucket-provider";
|
||||||
@@ -47,8 +39,6 @@ export const ShowGitProviders = () => {
|
|||||||
const { data, isPending, refetch } = api.gitProvider.getAll.useQuery();
|
const { data, isPending, refetch } = api.gitProvider.getAll.useQuery();
|
||||||
const { mutateAsync, isPending: isRemoving } =
|
const { mutateAsync, isPending: isRemoving } =
|
||||||
api.gitProvider.remove.useMutation();
|
api.gitProvider.remove.useMutation();
|
||||||
const { mutateAsync: toggleShare, isPending: isToggling } =
|
|
||||||
api.gitProvider.toggleShare.useMutation();
|
|
||||||
const url = useUrl();
|
const url = useUrl();
|
||||||
|
|
||||||
const getGitlabUrl = (
|
const getGitlabUrl = (
|
||||||
@@ -164,62 +154,10 @@ export const ShowGitProviders = () => {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!gitProvider.isOwner && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<Users className="size-3 mr-1" />
|
|
||||||
Shared
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row gap-1 items-center">
|
<div className="flex flex-row gap-1 items-center">
|
||||||
{gitProvider.isOwner && (
|
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex items-center gap-1.5 mr-2">
|
|
||||||
<Users className="size-4 text-muted-foreground" />
|
|
||||||
<Switch
|
|
||||||
disabled={isToggling}
|
|
||||||
checked={
|
|
||||||
gitProvider.sharedWithOrganization
|
|
||||||
}
|
|
||||||
onCheckedChange={async (
|
|
||||||
checked,
|
|
||||||
) => {
|
|
||||||
await toggleShare({
|
|
||||||
gitProviderId:
|
|
||||||
gitProvider.gitProviderId,
|
|
||||||
sharedWithOrganization: checked,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success(
|
|
||||||
checked
|
|
||||||
? "Provider shared with organization"
|
|
||||||
: "Provider unshared",
|
|
||||||
);
|
|
||||||
refetch();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error(
|
|
||||||
"Error updating sharing",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
Share with entire organization
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isBitbucket &&
|
{isBitbucket &&
|
||||||
gitProvider.bitbucket?.appPassword &&
|
gitProvider.bitbucket?.appPassword &&
|
||||||
!gitProvider.bitbucket?.apiToken ? (
|
!gitProvider.bitbucket?.apiToken ? (
|
||||||
@@ -284,71 +222,62 @@ export const ShowGitProviders = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{gitProvider.isOwner && (
|
{isGithub && haveGithubRequirements && (
|
||||||
<>
|
<EditGithubProvider
|
||||||
{isGithub && haveGithubRequirements && (
|
githubId={gitProvider.github?.githubId}
|
||||||
<EditGithubProvider
|
/>
|
||||||
githubId={gitProvider.github?.githubId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isGitlab && (
|
|
||||||
<EditGitlabProvider
|
|
||||||
gitlabId={gitProvider.gitlab?.gitlabId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isBitbucket && (
|
|
||||||
<EditBitbucketProvider
|
|
||||||
bitbucketId={
|
|
||||||
gitProvider.bitbucket?.bitbucketId
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isGitea && (
|
|
||||||
<EditGiteaProvider
|
|
||||||
giteaId={gitProvider.gitea?.giteaId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogAction
|
|
||||||
title="Delete Git Provider"
|
|
||||||
description={
|
|
||||||
gitProvider.sharedWithOrganization
|
|
||||||
? "This provider is shared with the organization. Deleting it will remove access for all members. Are you sure?"
|
|
||||||
: "Are you sure you want to delete this Git Provider?"
|
|
||||||
}
|
|
||||||
type="destructive"
|
|
||||||
onClick={async () => {
|
|
||||||
await mutateAsync({
|
|
||||||
gitProviderId:
|
|
||||||
gitProvider.gitProviderId,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success(
|
|
||||||
"Git Provider deleted successfully",
|
|
||||||
);
|
|
||||||
refetch();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error(
|
|
||||||
"Error deleting Git Provider",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isGitlab && (
|
||||||
|
<EditGitlabProvider
|
||||||
|
gitlabId={gitProvider.gitlab?.gitlabId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isBitbucket && (
|
||||||
|
<EditBitbucketProvider
|
||||||
|
bitbucketId={
|
||||||
|
gitProvider.bitbucket?.bitbucketId
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isGitea && (
|
||||||
|
<EditGiteaProvider
|
||||||
|
giteaId={gitProvider.gitea?.giteaId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogAction
|
||||||
|
title="Delete Git Provider"
|
||||||
|
description="Are you sure you want to delete this Git Provider?"
|
||||||
|
type="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
await mutateAsync({
|
||||||
|
gitProviderId: gitProvider.gitProviderId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success(
|
||||||
|
"Git Provider deleted successfully",
|
||||||
|
);
|
||||||
|
refetch();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error(
|
||||||
|
"Error deleting Git Provider",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10"
|
||||||
|
isLoading={isRemoving}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
DiscordIcon,
|
DiscordIcon,
|
||||||
GotifyIcon,
|
GotifyIcon,
|
||||||
LarkIcon,
|
LarkIcon,
|
||||||
MattermostIcon,
|
|
||||||
NtfyIcon,
|
NtfyIcon,
|
||||||
PushoverIcon,
|
PushoverIcon,
|
||||||
ResendIcon,
|
ResendIcon,
|
||||||
@@ -54,7 +53,6 @@ const notificationBaseSchema = z.object({
|
|||||||
appDeploy: z.boolean().default(false),
|
appDeploy: z.boolean().default(false),
|
||||||
appBuildError: z.boolean().default(false),
|
appBuildError: z.boolean().default(false),
|
||||||
databaseBackup: z.boolean().default(false),
|
databaseBackup: z.boolean().default(false),
|
||||||
dokployBackup: z.boolean().default(false),
|
|
||||||
volumeBackup: z.boolean().default(false),
|
volumeBackup: z.boolean().default(false),
|
||||||
dokployRestart: z.boolean().default(false),
|
dokployRestart: z.boolean().default(false),
|
||||||
dockerCleanup: z.boolean().default(false),
|
dockerCleanup: z.boolean().default(false),
|
||||||
@@ -136,14 +134,6 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
|||||||
priority: z.number().min(1).max(5).default(3),
|
priority: z.number().min(1).max(5).default(3),
|
||||||
})
|
})
|
||||||
.merge(notificationBaseSchema),
|
.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
|
z
|
||||||
.object({
|
.object({
|
||||||
type: z.literal("pushover"),
|
type: z.literal("pushover"),
|
||||||
@@ -220,10 +210,6 @@ export const notificationsMap = {
|
|||||||
icon: <NtfyIcon />,
|
icon: <NtfyIcon />,
|
||||||
label: "ntfy",
|
label: "ntfy",
|
||||||
},
|
},
|
||||||
mattermost: {
|
|
||||||
icon: <MattermostIcon />,
|
|
||||||
label: "Mattermost",
|
|
||||||
},
|
|
||||||
pushover: {
|
pushover: {
|
||||||
icon: <PushoverIcon />,
|
icon: <PushoverIcon />,
|
||||||
label: "Pushover",
|
label: "Pushover",
|
||||||
@@ -267,16 +253,14 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
api.notification.testGotifyConnection.useMutation();
|
api.notification.testGotifyConnection.useMutation();
|
||||||
const { mutateAsync: testNtfyConnection, isPending: isLoadingNtfy } =
|
const { mutateAsync: testNtfyConnection, isPending: isLoadingNtfy } =
|
||||||
api.notification.testNtfyConnection.useMutation();
|
api.notification.testNtfyConnection.useMutation();
|
||||||
const {
|
|
||||||
mutateAsync: testMattermostConnection,
|
|
||||||
isPending: isLoadingMattermost,
|
|
||||||
} = api.notification.testMattermostConnection.useMutation();
|
|
||||||
const { mutateAsync: testLarkConnection, isPending: isLoadingLark } =
|
const { mutateAsync: testLarkConnection, isPending: isLoadingLark } =
|
||||||
api.notification.testLarkConnection.useMutation();
|
api.notification.testLarkConnection.useMutation();
|
||||||
const { mutateAsync: testTeamsConnection, isPending: isLoadingTeams } =
|
const { mutateAsync: testTeamsConnection, isPending: isLoadingTeams } =
|
||||||
api.notification.testTeamsConnection.useMutation();
|
api.notification.testTeamsConnection.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: testCustomConnection, isPending: isLoadingCustom } =
|
const { mutateAsync: testCustomConnection, isPending: isLoadingCustom } =
|
||||||
api.notification.testCustomConnection.useMutation();
|
api.notification.testCustomConnection.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: testPushoverConnection, isPending: isLoadingPushover } =
|
const { mutateAsync: testPushoverConnection, isPending: isLoadingPushover } =
|
||||||
api.notification.testPushoverConnection.useMutation();
|
api.notification.testPushoverConnection.useMutation();
|
||||||
|
|
||||||
@@ -304,9 +288,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
const ntfyMutation = notificationId
|
const ntfyMutation = notificationId
|
||||||
? api.notification.updateNtfy.useMutation()
|
? api.notification.updateNtfy.useMutation()
|
||||||
: api.notification.createNtfy.useMutation();
|
: api.notification.createNtfy.useMutation();
|
||||||
const mattermostMutation = notificationId
|
|
||||||
? api.notification.updateMattermost.useMutation()
|
|
||||||
: api.notification.createMattermost.useMutation();
|
|
||||||
const larkMutation = notificationId
|
const larkMutation = notificationId
|
||||||
? api.notification.updateLark.useMutation()
|
? api.notification.updateLark.useMutation()
|
||||||
: api.notification.createLark.useMutation();
|
: api.notification.createLark.useMutation();
|
||||||
@@ -356,7 +337,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
dokployBackup: notification.dokployBackup,
|
|
||||||
volumeBackup: notification.volumeBackup,
|
volumeBackup: notification.volumeBackup,
|
||||||
dockerCleanup: notification.dockerCleanup,
|
dockerCleanup: notification.dockerCleanup,
|
||||||
webhookUrl: notification.slack?.webhookUrl,
|
webhookUrl: notification.slack?.webhookUrl,
|
||||||
@@ -371,7 +351,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
dokployBackup: notification.dokployBackup,
|
|
||||||
volumeBackup: notification.volumeBackup,
|
volumeBackup: notification.volumeBackup,
|
||||||
botToken: notification.telegram?.botToken,
|
botToken: notification.telegram?.botToken,
|
||||||
messageThreadId: notification.telegram?.messageThreadId || "",
|
messageThreadId: notification.telegram?.messageThreadId || "",
|
||||||
@@ -387,7 +366,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
dokployBackup: notification.dokployBackup,
|
|
||||||
volumeBackup: notification.volumeBackup,
|
volumeBackup: notification.volumeBackup,
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
webhookUrl: notification.discord?.webhookUrl,
|
webhookUrl: notification.discord?.webhookUrl,
|
||||||
@@ -402,7 +380,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
dokployBackup: notification.dokployBackup,
|
|
||||||
volumeBackup: notification.volumeBackup,
|
volumeBackup: notification.volumeBackup,
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
smtpServer: notification.email?.smtpServer,
|
smtpServer: notification.email?.smtpServer,
|
||||||
@@ -421,7 +398,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
dokployBackup: notification.dokployBackup,
|
|
||||||
volumeBackup: notification.volumeBackup,
|
volumeBackup: notification.volumeBackup,
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
apiKey: notification.resend?.apiKey,
|
apiKey: notification.resend?.apiKey,
|
||||||
@@ -437,7 +413,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
dokployBackup: notification.dokployBackup,
|
|
||||||
volumeBackup: notification.volumeBackup,
|
volumeBackup: notification.volumeBackup,
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
appToken: notification.gotify?.appToken,
|
appToken: notification.gotify?.appToken,
|
||||||
@@ -453,7 +428,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
dokployBackup: notification.dokployBackup,
|
|
||||||
volumeBackup: notification.volumeBackup,
|
volumeBackup: notification.volumeBackup,
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
accessToken: notification.ntfy?.accessToken || "",
|
accessToken: notification.ntfy?.accessToken || "",
|
||||||
@@ -464,29 +438,12 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
dockerCleanup: notification.dockerCleanup,
|
dockerCleanup: notification.dockerCleanup,
|
||||||
serverThreshold: notification.serverThreshold,
|
serverThreshold: notification.serverThreshold,
|
||||||
});
|
});
|
||||||
} else if (notification.notificationType === "mattermost") {
|
|
||||||
form.reset({
|
|
||||||
appBuildError: notification.appBuildError,
|
|
||||||
appDeploy: notification.appDeploy,
|
|
||||||
dokployRestart: notification.dokployRestart,
|
|
||||||
databaseBackup: notification.databaseBackup,
|
|
||||||
dokployBackup: notification.dokployBackup,
|
|
||||||
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") {
|
} else if (notification.notificationType === "lark") {
|
||||||
form.reset({
|
form.reset({
|
||||||
appBuildError: notification.appBuildError,
|
appBuildError: notification.appBuildError,
|
||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
dokployBackup: notification.dokployBackup,
|
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
webhookUrl: notification.lark?.webhookUrl,
|
webhookUrl: notification.lark?.webhookUrl,
|
||||||
name: notification.name,
|
name: notification.name,
|
||||||
@@ -500,7 +457,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
dokployBackup: notification.dokployBackup,
|
|
||||||
volumeBackup: notification.volumeBackup,
|
volumeBackup: notification.volumeBackup,
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
webhookUrl: notification.teams?.webhookUrl,
|
webhookUrl: notification.teams?.webhookUrl,
|
||||||
@@ -514,7 +470,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
dokployBackup: notification.dokployBackup,
|
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
endpoint: notification.custom?.endpoint || "",
|
endpoint: notification.custom?.endpoint || "",
|
||||||
headers: notification.custom?.headers
|
headers: notification.custom?.headers
|
||||||
@@ -536,7 +491,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
dokployBackup: notification.dokployBackup,
|
|
||||||
volumeBackup: notification.volumeBackup,
|
volumeBackup: notification.volumeBackup,
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
userKey: notification.pushover?.userKey,
|
userKey: notification.pushover?.userKey,
|
||||||
@@ -562,7 +516,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
resend: resendMutation,
|
resend: resendMutation,
|
||||||
gotify: gotifyMutation,
|
gotify: gotifyMutation,
|
||||||
ntfy: ntfyMutation,
|
ntfy: ntfyMutation,
|
||||||
mattermost: mattermostMutation,
|
|
||||||
lark: larkMutation,
|
lark: larkMutation,
|
||||||
teams: teamsMutation,
|
teams: teamsMutation,
|
||||||
custom: customMutation,
|
custom: customMutation,
|
||||||
@@ -575,7 +528,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy,
|
appDeploy,
|
||||||
dokployRestart,
|
dokployRestart,
|
||||||
databaseBackup,
|
databaseBackup,
|
||||||
dokployBackup,
|
|
||||||
volumeBackup,
|
volumeBackup,
|
||||||
dockerCleanup,
|
dockerCleanup,
|
||||||
serverThreshold,
|
serverThreshold,
|
||||||
@@ -587,7 +539,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
dokployBackup: dokployBackup,
|
|
||||||
volumeBackup: volumeBackup,
|
volumeBackup: volumeBackup,
|
||||||
webhookUrl: data.webhookUrl,
|
webhookUrl: data.webhookUrl,
|
||||||
channel: data.channel,
|
channel: data.channel,
|
||||||
@@ -603,7 +554,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
dokployBackup: dokployBackup,
|
|
||||||
volumeBackup: volumeBackup,
|
volumeBackup: volumeBackup,
|
||||||
botToken: data.botToken,
|
botToken: data.botToken,
|
||||||
messageThreadId: data.messageThreadId || "",
|
messageThreadId: data.messageThreadId || "",
|
||||||
@@ -620,7 +570,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
dokployBackup: dokployBackup,
|
|
||||||
volumeBackup: volumeBackup,
|
volumeBackup: volumeBackup,
|
||||||
webhookUrl: data.webhookUrl,
|
webhookUrl: data.webhookUrl,
|
||||||
decoration: data.decoration,
|
decoration: data.decoration,
|
||||||
@@ -636,7 +585,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
dokployBackup: dokployBackup,
|
|
||||||
volumeBackup: volumeBackup,
|
volumeBackup: volumeBackup,
|
||||||
smtpServer: data.smtpServer,
|
smtpServer: data.smtpServer,
|
||||||
smtpPort: data.smtpPort,
|
smtpPort: data.smtpPort,
|
||||||
@@ -656,7 +604,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
dokployBackup: dokployBackup,
|
|
||||||
volumeBackup: volumeBackup,
|
volumeBackup: volumeBackup,
|
||||||
apiKey: data.apiKey,
|
apiKey: data.apiKey,
|
||||||
fromAddress: data.fromAddress,
|
fromAddress: data.fromAddress,
|
||||||
@@ -673,7 +620,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
dokployBackup: dokployBackup,
|
|
||||||
volumeBackup: volumeBackup,
|
volumeBackup: volumeBackup,
|
||||||
serverUrl: data.serverUrl,
|
serverUrl: data.serverUrl,
|
||||||
appToken: data.appToken,
|
appToken: data.appToken,
|
||||||
@@ -690,7 +636,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
dokployBackup: dokployBackup,
|
|
||||||
volumeBackup: volumeBackup,
|
volumeBackup: volumeBackup,
|
||||||
serverUrl: data.serverUrl,
|
serverUrl: data.serverUrl,
|
||||||
accessToken: data.accessToken || "",
|
accessToken: data.accessToken || "",
|
||||||
@@ -701,30 +646,12 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
notificationId: notificationId || "",
|
notificationId: notificationId || "",
|
||||||
ntfyId: notification?.ntfyId || "",
|
ntfyId: notification?.ntfyId || "",
|
||||||
});
|
});
|
||||||
} else if (data.type === "mattermost") {
|
|
||||||
promise = mattermostMutation.mutateAsync({
|
|
||||||
appBuildError: appBuildError,
|
|
||||||
appDeploy: appDeploy,
|
|
||||||
dokployRestart: dokployRestart,
|
|
||||||
databaseBackup: databaseBackup,
|
|
||||||
dokployBackup: dokployBackup,
|
|
||||||
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") {
|
} else if (data.type === "lark") {
|
||||||
promise = larkMutation.mutateAsync({
|
promise = larkMutation.mutateAsync({
|
||||||
appBuildError: appBuildError,
|
appBuildError: appBuildError,
|
||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
dokployBackup: dokployBackup,
|
|
||||||
volumeBackup: volumeBackup,
|
volumeBackup: volumeBackup,
|
||||||
webhookUrl: data.webhookUrl,
|
webhookUrl: data.webhookUrl,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
@@ -739,7 +666,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
dokployBackup: dokployBackup,
|
|
||||||
volumeBackup: volumeBackup,
|
volumeBackup: volumeBackup,
|
||||||
webhookUrl: data.webhookUrl,
|
webhookUrl: data.webhookUrl,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
@@ -766,7 +692,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
dokployBackup: dokployBackup,
|
|
||||||
volumeBackup: volumeBackup,
|
volumeBackup: volumeBackup,
|
||||||
endpoint: data.endpoint,
|
endpoint: data.endpoint,
|
||||||
headers: headersRecord,
|
headers: headersRecord,
|
||||||
@@ -786,7 +711,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
dokployBackup: dokployBackup,
|
|
||||||
volumeBackup: volumeBackup,
|
volumeBackup: volumeBackup,
|
||||||
userKey: data.userKey,
|
userKey: data.userKey,
|
||||||
apiToken: data.apiToken,
|
apiToken: data.apiToken,
|
||||||
@@ -1482,62 +1406,6 @@ 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" && (
|
{type === "custom" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
@@ -1624,7 +1492,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{type === "lark" && (
|
{type === "lark" && (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
@@ -1882,27 +1749,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="dokployBackup"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel>Dokploy Backup</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Trigger the action when a dokploy backup is created.
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="volumeBackup"
|
name="volumeBackup"
|
||||||
@@ -2006,7 +1852,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
isLoadingResend ||
|
isLoadingResend ||
|
||||||
isLoadingGotify ||
|
isLoadingGotify ||
|
||||||
isLoadingNtfy ||
|
isLoadingNtfy ||
|
||||||
isLoadingMattermost ||
|
|
||||||
isLoadingLark ||
|
isLoadingLark ||
|
||||||
isLoadingTeams ||
|
isLoadingTeams ||
|
||||||
isLoadingCustom ||
|
isLoadingCustom ||
|
||||||
@@ -2066,12 +1911,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
accessToken: data.accessToken || "",
|
accessToken: data.accessToken || "",
|
||||||
priority: data.priority ?? 0,
|
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") {
|
} else if (data.type === "lark") {
|
||||||
await testLarkConnection({
|
await testLarkConnection({
|
||||||
webhookUrl: data.webhookUrl,
|
webhookUrl: data.webhookUrl,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
DiscordIcon,
|
DiscordIcon,
|
||||||
GotifyIcon,
|
GotifyIcon,
|
||||||
LarkIcon,
|
LarkIcon,
|
||||||
MattermostIcon,
|
|
||||||
NtfyIcon,
|
NtfyIcon,
|
||||||
ResendIcon,
|
ResendIcon,
|
||||||
SlackIcon,
|
SlackIcon,
|
||||||
@@ -122,12 +121,6 @@ export const ShowNotifications = () => {
|
|||||||
<TeamsIcon className="size-7 text-muted-foreground" />
|
<TeamsIcon className="size-7 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{notification.notificationType ===
|
|
||||||
"mattermost" && (
|
|
||||||
<div className="flex items-center justify-center rounded-lg">
|
|
||||||
<MattermostIcon className="size-7" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{notification.name}
|
{notification.name}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { InputOTP } from "@/components/ui/input-otp";
|
import {
|
||||||
|
InputOTP,
|
||||||
|
InputOTPGroup,
|
||||||
|
InputOTPSlot,
|
||||||
|
} from "@/components/ui/input-otp";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -419,14 +423,23 @@ export const Enable2FA = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col justify-center items-center">
|
||||||
<FormLabel>Verification Code</FormLabel>
|
<FormLabel>Verification Code</FormLabel>
|
||||||
<InputOTP
|
<InputOTP
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
value={otpValue}
|
value={otpValue}
|
||||||
onChange={setOtpValue}
|
onChange={setOtpValue}
|
||||||
autoFocus
|
autoComplete="off"
|
||||||
/>
|
>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={0} />
|
||||||
|
<InputOTPSlot index={1} />
|
||||||
|
<InputOTPSlot index={2} />
|
||||||
|
<InputOTPSlot index={3} />
|
||||||
|
<InputOTPSlot index={4} />
|
||||||
|
<InputOTPSlot index={5} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Enter the 6-digit code from your authenticator app
|
Enter the 6-digit code from your authenticator app
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
|||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
@@ -410,10 +409,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="root" {...field} />
|
<Input placeholder="root" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
|
||||||
Use "root" or a non-root user with passwordless
|
|
||||||
sudo access.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -118,10 +118,9 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div id="hook-form-add-gitlab" className="grid w-full gap-4">
|
<div id="hook-form-add-gitlab" className="grid w-full gap-4">
|
||||||
<AlertBlock type="info">
|
<AlertBlock type="warning">
|
||||||
You can connect as root or as a non-root user with passwordless
|
Using a root user is required to ensure everything works as
|
||||||
sudo access. If using a non-root user, ensure passwordless sudo is
|
expected.
|
||||||
configured.
|
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
|
|
||||||
<Tabs defaultValue="ssh-keys">
|
<Tabs defaultValue="ssh-keys">
|
||||||
@@ -161,7 +160,7 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => {
|
|||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
1. Add the public SSH Key when you create a server in your
|
1. Add the public SSH Key when you create a server in your
|
||||||
preferred provider (Hostinger, Digital Ocean, Hetzner,
|
preffered provider (Hostinger, Digital Ocean, Hetzner,
|
||||||
etc){" "}
|
etc){" "}
|
||||||
</li>
|
</li>
|
||||||
<li>2. Add The SSH Key to Server Manually</li>
|
<li>2. Add The SSH Key to Server Manually</li>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import { ShowMonitoringModal } from "./show-monitoring-modal";
|
|||||||
import { ShowSchedulesModal } from "./show-schedules-modal";
|
import { ShowSchedulesModal } from "./show-schedules-modal";
|
||||||
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
|
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
|
||||||
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
|
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
|
||||||
import { WelcomeSubscription } from "./welcome-stripe/welcome-subscription";
|
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
|
||||||
|
|
||||||
export const ShowServers = () => {
|
export const ShowServers = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -63,7 +63,7 @@ export const ShowServers = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{query?.success && isCloud && <WelcomeSubscription />}
|
{query?.success && isCloud && <WelcomeSuscription />}
|
||||||
<Card className="h-full p-2.5 rounded-xl max-w-5xl mx-auto">
|
<Card className="h-full p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||||
<div className="rounded-xl bg-background shadow-md ">
|
<div className="rounded-xl bg-background shadow-md ">
|
||||||
<CardHeader className="">
|
<CardHeader className="">
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Card } from "@/components/ui/card";
|
|
||||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { ShowSwarmContainers } from "../../swarm/containers/show-swarm-containers";
|
|
||||||
import SwarmMonitorCard from "../../swarm/monitoring-card";
|
import SwarmMonitorCard from "../../swarm/monitoring-card";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -24,24 +21,9 @@ export const ShowSwarmOverviewModal = ({ serverId }: Props) => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-7xl ">
|
<DialogContent className="sm:max-w-7xl ">
|
||||||
<Tabs defaultValue="overview">
|
<div className="grid w-full gap-1">
|
||||||
<TabsList>
|
<SwarmMonitorCard serverId={serverId} />
|
||||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
</div>
|
||||||
<TabsTrigger value="containers">Containers</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent value="overview">
|
|
||||||
<div className="grid w-full gap-1">
|
|
||||||
<SwarmMonitorCard serverId={serverId} />
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="containers">
|
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
|
||||||
<div className="rounded-xl bg-background shadow-md p-6">
|
|
||||||
<ShowSwarmContainers serverId={serverId} />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -163,29 +163,6 @@ export const ValidateServer = ({ serverId }: Props) => {
|
|||||||
: "Not Created"
|
: "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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user