mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-27 18:15:23 +02:00
Compare commits
4 Commits
main
...
feat/add-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a7da40ffe | ||
|
|
7a1703a191 | ||
|
|
f6af5daf5e | ||
|
|
452b9a3c78 |
79
.github/workflows/dokploy.yml
vendored
79
.github/workflows/dokploy.yml
vendored
@@ -138,8 +138,6 @@ jobs:
|
|||||||
needs: [combine-manifests]
|
needs: [combine-manifests]
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
|
||||||
version: ${{ steps.get_version.outputs.version }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -162,80 +160,3 @@ jobs:
|
|||||||
prerelease: false
|
prerelease: false
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
sync-version:
|
|
||||||
needs: [generate-release]
|
|
||||||
if: github.ref == 'refs/heads/main'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Sync version to MCP repository
|
|
||||||
run: |
|
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
|
|
||||||
cd /tmp/mcp-repo
|
|
||||||
|
|
||||||
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
|
|
||||||
mv package.json.tmp package.json
|
|
||||||
|
|
||||||
npm install -g pnpm
|
|
||||||
pnpm install
|
|
||||||
pnpm run fetch-openapi
|
|
||||||
pnpm run generate
|
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
|
||||||
git config user.email "bot@dokploy.com"
|
|
||||||
git add -A
|
|
||||||
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
|
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
|
||||||
--allow-empty
|
|
||||||
git push
|
|
||||||
|
|
||||||
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.version }}"
|
|
||||||
|
|
||||||
- name: Sync version to CLI repository
|
|
||||||
run: |
|
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
|
|
||||||
cd /tmp/cli-repo
|
|
||||||
|
|
||||||
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
|
|
||||||
mv package.json.tmp package.json
|
|
||||||
|
|
||||||
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
|
||||||
npm install -g pnpm
|
|
||||||
pnpm install
|
|
||||||
pnpm run generate
|
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
|
||||||
git config user.email "bot@dokploy.com"
|
|
||||||
git add -A
|
|
||||||
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
|
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
|
||||||
--allow-empty
|
|
||||||
git push
|
|
||||||
|
|
||||||
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.version }}"
|
|
||||||
|
|
||||||
- name: Sync version to SDK repository
|
|
||||||
run: |
|
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
|
|
||||||
cd /tmp/sdk-repo
|
|
||||||
|
|
||||||
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
|
|
||||||
mv package.json.tmp package.json
|
|
||||||
|
|
||||||
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
|
||||||
npm install -g pnpm
|
|
||||||
pnpm install
|
|
||||||
pnpm run generate
|
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
|
||||||
git config user.email "bot@dokploy.com"
|
|
||||||
git add -A
|
|
||||||
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
|
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
|
||||||
--allow-empty
|
|
||||||
git push
|
|
||||||
|
|
||||||
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.version }}"
|
|
||||||
|
|||||||
22
.github/workflows/pr-quality.yml
vendored
Normal file
22
.github/workflows/pr-quality.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
|
||||||
|
name: PR Quality
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
anti-slop:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: peakoss/anti-slop@v0
|
||||||
|
with:
|
||||||
|
max-failures: 4
|
||||||
|
blocked-commit-authors: "claude,copilot"
|
||||||
|
require-description: true
|
||||||
|
min-account-age: 5
|
||||||
63
.github/workflows/sync-openapi-docs.yml
vendored
63
.github/workflows/sync-openapi-docs.yml
vendored
@@ -68,66 +68,3 @@ jobs:
|
|||||||
|
|
||||||
echo "✅ OpenAPI synced to website successfully"
|
echo "✅ OpenAPI synced to website successfully"
|
||||||
|
|
||||||
- name: Sync to MCP repository
|
|
||||||
run: |
|
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git mcp-repo
|
|
||||||
|
|
||||||
cd mcp-repo
|
|
||||||
|
|
||||||
cp -f ../openapi.json openapi.json
|
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
|
||||||
git config user.email "bot@dokploy.com"
|
|
||||||
|
|
||||||
git add openapi.json
|
|
||||||
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
|
||||||
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
|
||||||
--allow-empty
|
|
||||||
|
|
||||||
git push
|
|
||||||
|
|
||||||
echo "✅ OpenAPI synced to MCP repository successfully"
|
|
||||||
|
|
||||||
- name: Sync to CLI repository
|
|
||||||
run: |
|
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git cli-repo
|
|
||||||
|
|
||||||
cd cli-repo
|
|
||||||
|
|
||||||
cp -f ../openapi.json openapi.json
|
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
|
||||||
git config user.email "bot@dokploy.com"
|
|
||||||
|
|
||||||
git add openapi.json
|
|
||||||
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
|
||||||
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
|
||||||
--allow-empty
|
|
||||||
|
|
||||||
git push
|
|
||||||
|
|
||||||
echo "✅ OpenAPI synced to CLI repository successfully"
|
|
||||||
|
|
||||||
- name: Sync to SDK repository
|
|
||||||
run: |
|
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git sdk-repo
|
|
||||||
|
|
||||||
cd sdk-repo
|
|
||||||
|
|
||||||
cp -f ../openapi.json openapi.json
|
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
|
||||||
git config user.email "bot@dokploy.com"
|
|
||||||
|
|
||||||
git add openapi.json
|
|
||||||
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
|
||||||
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
|
||||||
--allow-empty
|
|
||||||
|
|
||||||
git push
|
|
||||||
|
|
||||||
echo "✅ OpenAPI synced to SDK repository successfully"
|
|
||||||
|
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -4,8 +4,5 @@
|
|||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll.biome": "explicit",
|
"source.fixAll.biome": "explicit",
|
||||||
"source.organizeImports.biome": "explicit"
|
"source.organizeImports.biome": "explicit"
|
||||||
},
|
|
||||||
"[typescript]": {
|
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
|
|||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
|
HEALTHCHECK --interval=10s --timeout=3s --retries=10 \
|
||||||
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
||||||
|
|
||||||
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
|
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -1,11 +1,2 @@
|
|||||||
LEMON_SQUEEZY_API_KEY=""
|
LEMON_SQUEEZY_API_KEY=""
|
||||||
LEMON_SQUEEZY_STORE_ID=""
|
LEMON_SQUEEZY_STORE_ID=""
|
||||||
|
|
||||||
# Inngest (for GET /jobs - list deployment queue). Self-hosted example:
|
|
||||||
# INNGEST_BASE_URL="http://localhost:8288"
|
|
||||||
# Production: INNGEST_BASE_URL="https://dev-inngest.dokploy.com"
|
|
||||||
# INNGEST_SIGNING_KEY="your-signing-key"
|
|
||||||
# Optional: only events after this RFC3339 timestamp. If unset, no date filter is applied.
|
|
||||||
# INNGEST_EVENTS_RECEIVED_AFTER="2024-01-01T00:00:00Z"
|
|
||||||
# Max events to fetch when listing jobs (paginates with cursor). Default 100, max 10000.
|
|
||||||
# INNGEST_JOBS_MAX_EVENTS=100
|
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
type DeployJob,
|
type DeployJob,
|
||||||
deployJobSchema,
|
deployJobSchema,
|
||||||
} from "./schema.js";
|
} from "./schema.js";
|
||||||
import { fetchDeploymentJobs } from "./service.js";
|
|
||||||
import { deploy } from "./utils.js";
|
import { deploy } from "./utils.js";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -119,6 +118,7 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => {
|
|||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.log("error", error);
|
||||||
logger.error("Failed to send deployment event", error);
|
logger.error("Failed to send deployment event", error);
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
@@ -176,29 +176,6 @@ app.get("/health", async (c) => {
|
|||||||
return c.json({ status: "ok" });
|
return c.json({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
|
||||||
// List deployment jobs (Inngest runs) for a server - same shape as BullMQ queue for the UI
|
|
||||||
app.get("/jobs", async (c) => {
|
|
||||||
const serverId = c.req.query("serverId");
|
|
||||||
if (!serverId) {
|
|
||||||
return c.json({ message: "serverId is required" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rows = await fetchDeploymentJobs(serverId);
|
|
||||||
return c.json(rows);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
if (message.includes("INNGEST_BASE_URL")) {
|
|
||||||
return c.json(
|
|
||||||
{ message: "INNGEST_BASE_URL is required to list deployment jobs" },
|
|
||||||
503,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
logger.error("Failed to fetch jobs from Inngest", { serverId, error });
|
|
||||||
return c.json([], 200);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Serve Inngest functions endpoint
|
// Serve Inngest functions endpoint
|
||||||
app.on(
|
app.on(
|
||||||
["GET", "POST", "PUT"],
|
["GET", "POST", "PUT"],
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
import { logger } from "./logger.js";
|
|
||||||
|
|
||||||
const baseUrl = process.env.INNGEST_BASE_URL ?? "";
|
|
||||||
const signingKey = process.env.INNGEST_SIGNING_KEY ?? "";
|
|
||||||
|
|
||||||
const DEFAULT_MAX_EVENTS = 500;
|
|
||||||
const MAX_EVENTS = DEFAULT_MAX_EVENTS;
|
|
||||||
|
|
||||||
/** Event shape from GET /v1/events (https://api.inngest.com/v1/events) */
|
|
||||||
type InngestEventRow = {
|
|
||||||
internal_id?: string;
|
|
||||||
accountID?: string;
|
|
||||||
environmentID?: string;
|
|
||||||
source?: string;
|
|
||||||
sourceID?: string | null;
|
|
||||||
/** RFC3339 timestamp – API uses receivedAt, dev server may use received_at */
|
|
||||||
receivedAt?: string;
|
|
||||||
received_at?: string;
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
data: Record<string, unknown>;
|
|
||||||
user?: unknown;
|
|
||||||
ts: number;
|
|
||||||
v?: string | null;
|
|
||||||
metadata?: {
|
|
||||||
fetchedAt: string;
|
|
||||||
cachedUntil: string | null;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Run shape from GET /v1/events/{eventId}/runs – the actual job execution */
|
|
||||||
type InngestRun = {
|
|
||||||
run_id: string;
|
|
||||||
event_id: string;
|
|
||||||
status: string; // "Running" | "Completed" | "Failed" | "Cancelled" | "Queued"?
|
|
||||||
run_started_at?: string;
|
|
||||||
ended_at?: string | null;
|
|
||||||
output?: unknown;
|
|
||||||
// dev server / API may use different casing
|
|
||||||
run_started_at_ms?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getEventReceivedAt(ev: InngestEventRow): string | undefined {
|
|
||||||
return ev.receivedAt ?? ev.received_at;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Map Inngest run status to BullMQ-style state for the UI */
|
|
||||||
function runStatusToState(
|
|
||||||
status: string,
|
|
||||||
): "pending" | "active" | "completed" | "failed" | "cancelled" {
|
|
||||||
const s = status.toLowerCase();
|
|
||||||
if (s === "running") return "active";
|
|
||||||
if (s === "completed") return "completed";
|
|
||||||
if (s === "failed") return "failed";
|
|
||||||
if (s === "cancelled") return "cancelled";
|
|
||||||
if (s === "queued") return "pending";
|
|
||||||
return "pending";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fetchInngestEvents = async () => {
|
|
||||||
const maxEvents = MAX_EVENTS;
|
|
||||||
const all: InngestEventRow[] = [];
|
|
||||||
let cursor: string | undefined;
|
|
||||||
|
|
||||||
do {
|
|
||||||
const params = new URLSearchParams({ limit: "100" });
|
|
||||||
if (cursor) {
|
|
||||||
params.set("cursor", cursor);
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(`${baseUrl}/v1/events?${params}`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${signingKey}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
logger.warn("Inngest API error", {
|
|
||||||
status: res.status,
|
|
||||||
body: await res.text(),
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = (await res.json()) as {
|
|
||||||
data?: InngestEventRow[];
|
|
||||||
cursor?: string;
|
|
||||||
nextCursor?: string;
|
|
||||||
};
|
|
||||||
const data = Array.isArray(body.data) ? body.data : [];
|
|
||||||
all.push(...data);
|
|
||||||
|
|
||||||
// Next page: API may return cursor/nextCursor, or use last event's internal_id (per API docs)
|
|
||||||
const nextCursor =
|
|
||||||
body.cursor ?? body.nextCursor ?? data[data.length - 1]?.internal_id;
|
|
||||||
const hasMore = data.length === 100 && nextCursor && all.length < maxEvents;
|
|
||||||
cursor = hasMore ? nextCursor : undefined;
|
|
||||||
} while (cursor);
|
|
||||||
|
|
||||||
return all.slice(0, maxEvents);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Fetch runs for a single event (GET /v1/events/{eventId}/runs) – runs are the actual jobs */
|
|
||||||
export const fetchInngestRunsForEvent = async (
|
|
||||||
eventId: string,
|
|
||||||
): Promise<InngestRun[]> => {
|
|
||||||
const res = await fetch(
|
|
||||||
`${baseUrl}/v1/events/${encodeURIComponent(eventId)}/runs`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${signingKey}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!res.ok) {
|
|
||||||
logger.warn("Inngest runs API error", {
|
|
||||||
eventId,
|
|
||||||
status: res.status,
|
|
||||||
body: await res.text(),
|
|
||||||
});
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const body = (await res.json()) as { data?: InngestRun[] };
|
|
||||||
return Array.isArray(body.data) ? body.data : [];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** One row for the queue UI (BullMQ-compatible shape) */
|
|
||||||
export type DeploymentJobRow = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
data: Record<string, unknown>;
|
|
||||||
timestamp: number;
|
|
||||||
processedOn?: number;
|
|
||||||
finishedOn?: number;
|
|
||||||
failedReason?: string;
|
|
||||||
state: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Build queue rows from events + their runs (one row per run, or pending if no run yet) */
|
|
||||||
function buildDeploymentRowsFromRuns(
|
|
||||||
events: InngestEventRow[],
|
|
||||||
runsByEventId: Map<string, InngestRun[]>,
|
|
||||||
serverId: string,
|
|
||||||
): DeploymentJobRow[] {
|
|
||||||
const requested = events.filter(
|
|
||||||
(e) =>
|
|
||||||
e.name === "deployment/requested" &&
|
|
||||||
(e.data as Record<string, unknown>)?.serverId === serverId,
|
|
||||||
);
|
|
||||||
const rows: DeploymentJobRow[] = [];
|
|
||||||
|
|
||||||
for (const ev of requested) {
|
|
||||||
const data = (ev.data ?? {}) as Record<string, unknown>;
|
|
||||||
const runs = runsByEventId.get(ev.id) ?? [];
|
|
||||||
|
|
||||||
if (runs.length === 0) {
|
|
||||||
// Queued: event received but no run yet
|
|
||||||
rows.push({
|
|
||||||
id: ev.id,
|
|
||||||
name: ev.name,
|
|
||||||
data,
|
|
||||||
timestamp: ev.ts,
|
|
||||||
processedOn: ev.ts,
|
|
||||||
finishedOn: undefined,
|
|
||||||
failedReason: undefined,
|
|
||||||
state: "pending",
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const run of runs) {
|
|
||||||
const state = runStatusToState(run.status);
|
|
||||||
const runStartedMs =
|
|
||||||
run.run_started_at_ms ??
|
|
||||||
(run.run_started_at ? new Date(run.run_started_at).getTime() : ev.ts);
|
|
||||||
const endedMs = run.ended_at
|
|
||||||
? new Date(run.ended_at).getTime()
|
|
||||||
: undefined;
|
|
||||||
const failedReason =
|
|
||||||
state === "failed" &&
|
|
||||||
run.output &&
|
|
||||||
typeof run.output === "object" &&
|
|
||||||
"error" in run.output
|
|
||||||
? String((run.output as { error?: unknown }).error)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
rows.push({
|
|
||||||
id: run.run_id,
|
|
||||||
name: ev.name,
|
|
||||||
data,
|
|
||||||
timestamp: runStartedMs,
|
|
||||||
processedOn: runStartedMs,
|
|
||||||
finishedOn:
|
|
||||||
state === "completed" || state === "failed" || state === "cancelled"
|
|
||||||
? endedMs
|
|
||||||
: undefined,
|
|
||||||
failedReason,
|
|
||||||
state,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetch deployment jobs for a server: events → runs → rows (correct model: runs = jobs) */
|
|
||||||
export const fetchDeploymentJobs = async (
|
|
||||||
serverId: string,
|
|
||||||
): Promise<DeploymentJobRow[]> => {
|
|
||||||
if (!signingKey) {
|
|
||||||
logger.warn("INNGEST_SIGNING_KEY not set, returning empty jobs list");
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (!baseUrl) {
|
|
||||||
throw new Error("INNGEST_BASE_URL is required to list deployment jobs");
|
|
||||||
}
|
|
||||||
|
|
||||||
const events = await fetchInngestEvents();
|
|
||||||
|
|
||||||
const requestedForServer = events.filter(
|
|
||||||
(e) =>
|
|
||||||
e.name === "deployment/requested" &&
|
|
||||||
(e.data as Record<string, unknown>)?.serverId === serverId,
|
|
||||||
);
|
|
||||||
// Limit to avoid too many run fetches
|
|
||||||
const toFetch = requestedForServer.slice(0, 50);
|
|
||||||
const runsByEventId = new Map<string, InngestRun[]>();
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
toFetch.map(async (ev) => {
|
|
||||||
const runs = await fetchInngestRunsForEvent(ev.id);
|
|
||||||
runsByEventId.set(ev.id, runs);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return buildDeploymentRowsFromRuns(toFetch, runsByEventId, serverId);
|
|
||||||
};
|
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
updateCompose,
|
updateCompose,
|
||||||
updatePreviewDeployment,
|
updatePreviewDeployment,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import type { DeployJob } from "./schema.js";
|
import type { DeployJob } from "./schema";
|
||||||
|
|
||||||
export const deploy = async (job: DeployJob) => {
|
export const deploy = async (job: DeployJob) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
// Isolate the command builder from the compose-file I/O performed by
|
|
||||||
// writeDomainsToCompose; we only care about the docker invocation it emits.
|
|
||||||
vi.mock("@dokploy/server/utils/docker/domain", () => ({
|
|
||||||
writeDomainsToCompose: vi.fn().mockResolvedValue(""),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const baseCompose = {
|
|
||||||
appName: "my-app",
|
|
||||||
sourceType: "raw",
|
|
||||||
command: "",
|
|
||||||
composePath: "docker-compose.yml",
|
|
||||||
composeType: "stack",
|
|
||||||
isolatedDeployment: false,
|
|
||||||
randomize: false,
|
|
||||||
suffix: "",
|
|
||||||
serverId: null,
|
|
||||||
env: "",
|
|
||||||
mounts: [],
|
|
||||||
domains: [],
|
|
||||||
environment: { project: { env: "" }, env: "" },
|
|
||||||
} as unknown as Parameters<typeof getBuildComposeCommand>[0];
|
|
||||||
|
|
||||||
// Regression coverage for #4401: the deploy command runs under `env -i`, which
|
|
||||||
// clears the environment except for the vars listed explicitly. HOME must be
|
|
||||||
// preserved so docker can resolve ~/.docker/config.json — otherwise
|
|
||||||
// `docker stack deploy --with-registry-auth` ships no credentials to the swarm
|
|
||||||
// and private-registry images fail to pull.
|
|
||||||
describe("getBuildComposeCommand registry auth (#4401)", () => {
|
|
||||||
it("preserves HOME for swarm stack deploys", async () => {
|
|
||||||
const command = await getBuildComposeCommand({
|
|
||||||
...baseCompose,
|
|
||||||
composeType: "stack",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(command).toContain("stack deploy");
|
|
||||||
expect(command).toContain("--with-registry-auth");
|
|
||||||
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves HOME for docker compose deploys", async () => {
|
|
||||||
const command = await getBuildComposeCommand({
|
|
||||||
...baseCompose,
|
|
||||||
composeType: "docker-compose",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(command).toContain("compose -p my-app");
|
|
||||||
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -32,9 +32,6 @@ describe("Host rule format regression tests", () => {
|
|||||||
previewDeploymentId: "",
|
previewDeploymentId: "",
|
||||||
internalPath: "/",
|
internalPath: "/",
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
customEntrypoint: null,
|
|
||||||
middlewares: null,
|
|
||||||
forwardAuthEnabled: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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,8 +21,6 @@ describe("createDomainLabels", () => {
|
|||||||
previewDeploymentId: "",
|
previewDeploymentId: "",
|
||||||
internalPath: "/",
|
internalPath: "/",
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
middlewares: null,
|
|
||||||
forwardAuthEnabled: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
it("should create basic labels for web entrypoint", async () => {
|
it("should create basic labels for web entrypoint", async () => {
|
||||||
@@ -104,51 +101,6 @@ describe("createDomainLabels", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should add tls=true for certificateType none on websecure entrypoint", async () => {
|
|
||||||
const noneDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
certificateType: "none" as const,
|
|
||||||
};
|
|
||||||
const labels = await createDomainLabels(appName, noneDomain, "websecure");
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-websecure.tls=true",
|
|
||||||
);
|
|
||||||
// no cert resolver should be set when relying on a default/custom cert
|
|
||||||
expect(labels).not.toContain(
|
|
||||||
"traefik.http.routers.test-app-1-websecure.tls.certresolver=letsencrypt",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not add tls=true for certificateType none on web entrypoint", async () => {
|
|
||||||
const noneDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
certificateType: "none" as const,
|
|
||||||
};
|
|
||||||
const labels = await createDomainLabels(appName, noneDomain, "web");
|
|
||||||
expect(labels).not.toContain(
|
|
||||||
"traefik.http.routers.test-app-1-web.tls=true",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add tls=true for certificateType none on a custom https entrypoint", async () => {
|
|
||||||
const noneDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
customEntrypoint: "websecure-custom",
|
|
||||||
certificateType: "none" as const,
|
|
||||||
};
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
noneDomain,
|
|
||||||
"websecure-custom",
|
|
||||||
);
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-websecure-custom.tls=true",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle different ports correctly", async () => {
|
it("should handle different ports correctly", async () => {
|
||||||
const customPortDomain = { ...baseDomain, port: 3000 };
|
const customPortDomain = { ...baseDomain, port: 3000 };
|
||||||
const labels = await createDomainLabels(appName, customPortDomain, "web");
|
const labels = await createDomainLabels(appName, customPortDomain, "web");
|
||||||
@@ -219,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",
|
||||||
);
|
);
|
||||||
@@ -256,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",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -288,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();
|
||||||
@@ -244,7 +244,7 @@ services:
|
|||||||
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
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",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { shouldDeploy } from "@dokploy/server";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
describe("shouldDeploy", () => {
|
|
||||||
it("should deploy when no watch paths are configured", () => {
|
|
||||||
expect(shouldDeploy(null, ["src/index.ts"])).toBe(true);
|
|
||||||
expect(shouldDeploy([], ["src/index.ts"])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should deploy when watch paths match modified files", () => {
|
|
||||||
expect(shouldDeploy(["src/**"], ["src/index.ts"])).toBe(true);
|
|
||||||
expect(shouldDeploy(["apps/web/**"], ["apps/web/page.tsx"])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not deploy when watch paths do not match", () => {
|
|
||||||
expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not throw when modified files contain non-string values", () => {
|
|
||||||
expect(() =>
|
|
||||||
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
|
|
||||||
).not.toThrow();
|
|
||||||
expect(
|
|
||||||
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not throw when modified files are undefined or null", () => {
|
|
||||||
expect(() => shouldDeploy(["src/**"], undefined)).not.toThrow();
|
|
||||||
expect(() => shouldDeploy(["src/**"], null)).not.toThrow();
|
|
||||||
expect(shouldDeploy(["src/**"], undefined)).toBe(false);
|
|
||||||
expect(shouldDeploy(["src/**"], null)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not throw when every modified file is non-string", () => {
|
|
||||||
expect(() =>
|
|
||||||
shouldDeploy(["src/**"], [undefined, undefined] as any),
|
|
||||||
).not.toThrow();
|
|
||||||
expect(shouldDeploy(["src/**"], [undefined, undefined] as any)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,369 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import {
|
|
||||||
canEditDeployGitSource,
|
|
||||||
getAccessibleGitProviderIds,
|
|
||||||
} from "@dokploy/server/services/git-provider";
|
|
||||||
|
|
||||||
const mockDb = vi.hoisted(() => ({
|
|
||||||
query: {
|
|
||||||
gitProvider: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
},
|
|
||||||
member: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/db", () => ({ db: mockDb }));
|
|
||||||
|
|
||||||
const mockHasValidLicense = vi.hoisted(() => vi.fn());
|
|
||||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
|
||||||
hasValidLicense: mockHasValidLicense,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const ORG_ID = "org-1";
|
|
||||||
const USER_OWNER = "user-owner";
|
|
||||||
const USER_ADMIN = "user-admin";
|
|
||||||
const USER_MEMBER = "user-member";
|
|
||||||
const USER_MEMBER_2 = "user-member-2";
|
|
||||||
|
|
||||||
const providerOwned = {
|
|
||||||
gitProviderId: "gp-owned",
|
|
||||||
userId: USER_MEMBER,
|
|
||||||
sharedWithOrganization: false,
|
|
||||||
};
|
|
||||||
const providerShared = {
|
|
||||||
gitProviderId: "gp-shared",
|
|
||||||
userId: USER_OWNER,
|
|
||||||
sharedWithOrganization: true,
|
|
||||||
};
|
|
||||||
const providerPrivate = {
|
|
||||||
gitProviderId: "gp-private",
|
|
||||||
userId: USER_OWNER,
|
|
||||||
sharedWithOrganization: false,
|
|
||||||
};
|
|
||||||
const providerOtherMember = {
|
|
||||||
gitProviderId: "gp-other",
|
|
||||||
userId: USER_MEMBER_2,
|
|
||||||
sharedWithOrganization: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const allProviders = [
|
|
||||||
providerOwned,
|
|
||||||
providerShared,
|
|
||||||
providerPrivate,
|
|
||||||
providerOtherMember,
|
|
||||||
];
|
|
||||||
|
|
||||||
function session(userId: string) {
|
|
||||||
return { userId, activeOrganizationId: ORG_ID };
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockDb.query.gitProvider.findMany.mockResolvedValue(allProviders);
|
|
||||||
mockHasValidLicense.mockResolvedValue(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getAccessibleGitProviderIds", () => {
|
|
||||||
describe("owner", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "owner",
|
|
||||||
accessedGitProviders: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns all org providers", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_OWNER));
|
|
||||||
expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId)));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes providers owned by other members", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_OWNER));
|
|
||||||
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
|
||||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("admin", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "admin",
|
|
||||||
accessedGitProviders: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns all org providers", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
|
|
||||||
expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId)));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes providers owned by other members — fixes issue #4469", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
|
|
||||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
|
|
||||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("member without enterprise license", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "member",
|
|
||||||
accessedGitProviders: [providerPrivate.gitProviderId],
|
|
||||||
});
|
|
||||||
mockHasValidLicense.mockResolvedValue(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can access their own provider", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can access shared providers", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerShared.gitProviderId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cannot access private providers of other users even if assigned (no license)", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cannot access providers of other members", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("member with enterprise license", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockHasValidLicense.mockResolvedValue(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can access provider explicitly assigned to them", async () => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "member",
|
|
||||||
accessedGitProviders: [providerPrivate.gitProviderId],
|
|
||||||
});
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cannot access provider not assigned and not shared", async () => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "member",
|
|
||||||
accessedGitProviders: [],
|
|
||||||
});
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
|
||||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can access shared provider even without explicit assignment", async () => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "member",
|
|
||||||
accessedGitProviders: [],
|
|
||||||
});
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerShared.gitProviderId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can access own provider regardless of assignments", async () => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "member",
|
|
||||||
accessedGitProviders: [],
|
|
||||||
});
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cannot access provider of other member even with license but no assignment", async () => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "member",
|
|
||||||
accessedGitProviders: [],
|
|
||||||
});
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("member with no member record", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue(null);
|
|
||||||
mockHasValidLicense.mockResolvedValue(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("only returns own providers and shared ones", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
|
||||||
expect(ids.has(providerShared.gitProviderId)).toBe(true);
|
|
||||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("enterprise license — member assigned to a provider they do not own", () => {
|
|
||||||
// getAccessibleGitProviderIds still returns the provider (member can connect NEW deploys)
|
|
||||||
it("member assigned to owner's private provider can USE the provider for new deploys", async () => {
|
|
||||||
mockHasValidLicense.mockResolvedValue(true);
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "member",
|
|
||||||
accessedGitProviders: [providerPrivate.gitProviderId],
|
|
||||||
});
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member NOT assigned to owner's private provider cannot use it at all", async () => {
|
|
||||||
mockHasValidLicense.mockResolvedValue(true);
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "member",
|
|
||||||
accessedGitProviders: [],
|
|
||||||
});
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("empty org", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockDb.query.gitProvider.findMany.mockResolvedValue([]);
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "admin",
|
|
||||||
accessedGitProviders: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty set when org has no providers", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
|
|
||||||
expect(ids.size).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("canEditDeployGitSource", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockHasValidLicense.mockResolvedValue(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("owner", () => {
|
|
||||||
it("can edit deploy using any provider", async () => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({ role: "owner" });
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
providerPrivate.gitProviderId,
|
|
||||||
session(USER_OWNER),
|
|
||||||
);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("admin", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({ role: "admin" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cannot edit deploy using owner's private provider (not shared)", async () => {
|
|
||||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
|
||||||
userId: USER_OWNER,
|
|
||||||
sharedWithOrganization: false,
|
|
||||||
});
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
providerPrivate.gitProviderId,
|
|
||||||
session(USER_ADMIN),
|
|
||||||
);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can edit deploy using a provider shared with the org", async () => {
|
|
||||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
|
||||||
userId: USER_OWNER,
|
|
||||||
sharedWithOrganization: true,
|
|
||||||
});
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
providerShared.gitProviderId,
|
|
||||||
session(USER_ADMIN),
|
|
||||||
);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can edit deploy using their own provider", async () => {
|
|
||||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
|
||||||
userId: USER_ADMIN,
|
|
||||||
sharedWithOrganization: false,
|
|
||||||
});
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
"gp-admin-owned",
|
|
||||||
session(USER_ADMIN),
|
|
||||||
);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("member", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({ role: "member" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can edit deploy using their own provider", async () => {
|
|
||||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
|
||||||
userId: USER_MEMBER,
|
|
||||||
sharedWithOrganization: false,
|
|
||||||
});
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
providerOwned.gitProviderId,
|
|
||||||
session(USER_MEMBER),
|
|
||||||
);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can edit deploy using a provider shared with the org", async () => {
|
|
||||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
|
||||||
userId: USER_OWNER,
|
|
||||||
sharedWithOrganization: true,
|
|
||||||
});
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
providerShared.gitProviderId,
|
|
||||||
session(USER_MEMBER),
|
|
||||||
);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cannot edit deploy using owner's private provider even with enterprise license and assignment", async () => {
|
|
||||||
// This is the key case: enterprise, provider del owner, no compartido,
|
|
||||||
// member tiene accessedGitProviders asignado — pero NO puede cambiar la branch del deploy del owner
|
|
||||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
|
||||||
userId: USER_OWNER,
|
|
||||||
sharedWithOrganization: false,
|
|
||||||
});
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
providerPrivate.gitProviderId,
|
|
||||||
session(USER_MEMBER),
|
|
||||||
);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cannot edit deploy using another member's private provider", async () => {
|
|
||||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
|
||||||
userId: USER_MEMBER_2,
|
|
||||||
sharedWithOrganization: false,
|
|
||||||
});
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
providerOtherMember.gitProviderId,
|
|
||||||
session(USER_MEMBER),
|
|
||||||
);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false if provider does not exist", async () => {
|
|
||||||
mockDb.query.gitProvider.findFirst.mockResolvedValue(null);
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
"nonexistent-id",
|
|
||||||
session(USER_MEMBER),
|
|
||||||
);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const mockMemberData = (
|
|
||||||
role: string,
|
|
||||||
overrides: Record<string, boolean> = {},
|
|
||||||
) => ({
|
|
||||||
id: "member-1",
|
|
||||||
role,
|
|
||||||
userId: "user-1",
|
|
||||||
organizationId: "org-1",
|
|
||||||
accessedProjects: [] as string[],
|
|
||||||
accessedServices: [] as string[],
|
|
||||||
accessedEnvironments: [] as string[],
|
|
||||||
canCreateProjects: overrides.canCreateProjects ?? false,
|
|
||||||
canDeleteProjects: overrides.canDeleteProjects ?? false,
|
|
||||||
canCreateServices: overrides.canCreateServices ?? false,
|
|
||||||
canDeleteServices: overrides.canDeleteServices ?? false,
|
|
||||||
canCreateEnvironments: overrides.canCreateEnvironments ?? false,
|
|
||||||
canDeleteEnvironments: overrides.canDeleteEnvironments ?? false,
|
|
||||||
canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false,
|
|
||||||
canAccessToDocker: overrides.canAccessToDocker ?? false,
|
|
||||||
canAccessToAPI: overrides.canAccessToAPI ?? false,
|
|
||||||
canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false,
|
|
||||||
canAccessToGitProviders: overrides.canAccessToGitProviders ?? false,
|
|
||||||
user: { id: "user-1", email: "test@test.com" },
|
|
||||||
});
|
|
||||||
|
|
||||||
let memberToReturn: ReturnType<typeof mockMemberData> =
|
|
||||||
mockMemberData("member");
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/db", () => ({
|
|
||||||
db: {
|
|
||||||
query: {
|
|
||||||
member: {
|
|
||||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
|
||||||
findMany: vi.fn(() => Promise.resolve([])),
|
|
||||||
},
|
|
||||||
organizationRole: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
findMany: vi.fn(() => Promise.resolve([])),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
|
||||||
hasValidLicense: vi.fn(() => Promise.resolve(false)),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { checkPermission } = await import("@dokploy/server/services/permission");
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
user: { id: "user-1" },
|
|
||||||
session: { activeOrganizationId: "org-1" },
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("owner and admin bypass enterprise resources", () => {
|
|
||||||
it("owner bypasses deployment.read", async () => {
|
|
||||||
memberToReturn = mockMemberData("owner");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { deployment: ["read"] }),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("admin bypasses backup.create", async () => {
|
|
||||||
memberToReturn = mockMemberData("admin");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { backup: ["create"] }),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("owner bypasses multiple enterprise permissions at once", async () => {
|
|
||||||
memberToReturn = mockMemberData("owner");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, {
|
|
||||||
deployment: ["read"],
|
|
||||||
backup: ["create"],
|
|
||||||
domain: ["delete"],
|
|
||||||
}),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("member is denied org-level enterprise resources (CVE: bypass via staticRoles)", () => {
|
|
||||||
it("member is denied registry.read", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { registry: ["read"] }),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member is denied certificate.read", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { certificate: ["read"] }),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member is denied destination.read", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { destination: ["read"] }),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member is denied notification.read", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { notification: ["read"] }),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member is denied auditLog.read", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { auditLog: ["read"] }),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member is denied server.read", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(checkPermission(ctx, { server: ["read"] })).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member is denied registry.create", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { registry: ["create"] }),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("static roles validate free-tier resources", () => {
|
|
||||||
it("owner passes project.create", async () => {
|
|
||||||
memberToReturn = mockMemberData("owner");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { project: ["create"] }),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member fails project.create (no legacy override)", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { project: ["create"] }),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member passes service.read", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { service: ["read"] }),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member fails service.create", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { service: ["create"] }),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("legacy boolean overrides for member", () => {
|
|
||||||
it("member passes project.create with canCreateProjects=true", async () => {
|
|
||||||
memberToReturn = mockMemberData("member", { canCreateProjects: true });
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { project: ["create"] }),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member passes docker.read with canAccessToDocker=true", async () => {
|
|
||||||
memberToReturn = mockMemberData("member", { canAccessToDocker: true });
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { docker: ["read"] }),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member fails docker.read with canAccessToDocker=false", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(checkPermission(ctx, { docker: ["read"] })).rejects.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import {
|
|
||||||
enterpriseOnlyResources,
|
|
||||||
statements,
|
|
||||||
} from "@dokploy/server/lib/access-control";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
const FREE_TIER_RESOURCES = [
|
|
||||||
"organization",
|
|
||||||
"member",
|
|
||||||
"invitation",
|
|
||||||
"team",
|
|
||||||
"ac",
|
|
||||||
"project",
|
|
||||||
"service",
|
|
||||||
"environment",
|
|
||||||
"docker",
|
|
||||||
"sshKeys",
|
|
||||||
"gitProviders",
|
|
||||||
"traefikFiles",
|
|
||||||
"api",
|
|
||||||
];
|
|
||||||
|
|
||||||
const ENTERPRISE_RESOURCES = [
|
|
||||||
"volume",
|
|
||||||
"deployment",
|
|
||||||
"envVars",
|
|
||||||
"projectEnvVars",
|
|
||||||
"environmentEnvVars",
|
|
||||||
"server",
|
|
||||||
"registry",
|
|
||||||
"certificate",
|
|
||||||
"backup",
|
|
||||||
"volumeBackup",
|
|
||||||
"schedule",
|
|
||||||
"domain",
|
|
||||||
"destination",
|
|
||||||
"notification",
|
|
||||||
"tag",
|
|
||||||
"logs",
|
|
||||||
"monitoring",
|
|
||||||
"auditLog",
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("enterpriseOnlyResources set", () => {
|
|
||||||
it("contains all enterprise resources", () => {
|
|
||||||
for (const resource of ENTERPRISE_RESOURCES) {
|
|
||||||
expect(enterpriseOnlyResources.has(resource)).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT contain free-tier resources", () => {
|
|
||||||
for (const resource of FREE_TIER_RESOURCES) {
|
|
||||||
expect(enterpriseOnlyResources.has(resource)).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("every resource in statements is either free or enterprise", () => {
|
|
||||||
const allResources = Object.keys(statements);
|
|
||||||
for (const resource of allResources) {
|
|
||||||
const isFree = FREE_TIER_RESOURCES.includes(resource);
|
|
||||||
const isEnterprise = enterpriseOnlyResources.has(resource);
|
|
||||||
expect(isFree || isEnterprise).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("free and enterprise sets don't overlap", () => {
|
|
||||||
for (const resource of FREE_TIER_RESOURCES) {
|
|
||||||
expect(enterpriseOnlyResources.has(resource)).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("all statement resources are accounted for", () => {
|
|
||||||
const allResources = Object.keys(statements);
|
|
||||||
const categorized = [...FREE_TIER_RESOURCES, ...ENTERPRISE_RESOURCES];
|
|
||||||
for (const resource of allResources) {
|
|
||||||
expect(categorized).toContain(resource);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const mockMemberData = (
|
|
||||||
role: string,
|
|
||||||
overrides: Record<string, boolean> = {},
|
|
||||||
) => ({
|
|
||||||
id: "member-1",
|
|
||||||
role,
|
|
||||||
userId: "user-1",
|
|
||||||
organizationId: "org-1",
|
|
||||||
accessedProjects: [] as string[],
|
|
||||||
accessedServices: [] as string[],
|
|
||||||
accessedEnvironments: [] as string[],
|
|
||||||
canCreateProjects: overrides.canCreateProjects ?? false,
|
|
||||||
canDeleteProjects: overrides.canDeleteProjects ?? false,
|
|
||||||
canCreateServices: overrides.canCreateServices ?? false,
|
|
||||||
canDeleteServices: overrides.canDeleteServices ?? false,
|
|
||||||
canCreateEnvironments: overrides.canCreateEnvironments ?? false,
|
|
||||||
canDeleteEnvironments: overrides.canDeleteEnvironments ?? false,
|
|
||||||
canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false,
|
|
||||||
canAccessToDocker: overrides.canAccessToDocker ?? false,
|
|
||||||
canAccessToAPI: overrides.canAccessToAPI ?? false,
|
|
||||||
canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false,
|
|
||||||
canAccessToGitProviders: overrides.canAccessToGitProviders ?? false,
|
|
||||||
user: { id: "user-1", email: "test@test.com" },
|
|
||||||
});
|
|
||||||
|
|
||||||
let memberToReturn: ReturnType<typeof mockMemberData> =
|
|
||||||
mockMemberData("member");
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/db", () => ({
|
|
||||||
db: {
|
|
||||||
query: {
|
|
||||||
member: {
|
|
||||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
|
||||||
findMany: vi.fn(() => Promise.resolve([])),
|
|
||||||
},
|
|
||||||
organizationRole: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
findMany: vi.fn(() => Promise.resolve([])),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
|
||||||
hasValidLicense: vi.fn(() => Promise.resolve(false)),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { resolvePermissions } = await import(
|
|
||||||
"@dokploy/server/services/permission"
|
|
||||||
);
|
|
||||||
const { enterpriseOnlyResources, statements } = await import(
|
|
||||||
"@dokploy/server/lib/access-control"
|
|
||||||
);
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
user: { id: "user-1" },
|
|
||||||
session: { activeOrganizationId: "org-1" },
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("enterprise resources for static roles", () => {
|
|
||||||
it("owner gets true for all enterprise resources", async () => {
|
|
||||||
memberToReturn = mockMemberData("owner");
|
|
||||||
const perms = await resolvePermissions(ctx);
|
|
||||||
|
|
||||||
for (const resource of enterpriseOnlyResources) {
|
|
||||||
const actions = statements[resource as keyof typeof statements];
|
|
||||||
for (const action of actions) {
|
|
||||||
expect((perms as any)[resource][action]).toBe(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("admin gets true for all enterprise resources", async () => {
|
|
||||||
memberToReturn = mockMemberData("admin");
|
|
||||||
const perms = await resolvePermissions(ctx);
|
|
||||||
|
|
||||||
for (const resource of enterpriseOnlyResources) {
|
|
||||||
const actions = statements[resource as keyof typeof statements];
|
|
||||||
for (const action of actions) {
|
|
||||||
expect((perms as any)[resource][action]).toBe(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member gets true for service-level enterprise resources", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
const perms = await resolvePermissions(ctx);
|
|
||||||
|
|
||||||
expect(perms.deployment.read).toBe(true);
|
|
||||||
expect(perms.deployment.create).toBe(true);
|
|
||||||
expect(perms.domain.read).toBe(true);
|
|
||||||
expect(perms.backup.read).toBe(true);
|
|
||||||
expect(perms.logs.read).toBe(true);
|
|
||||||
expect(perms.monitoring.read).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member gets false for org-level enterprise resources", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
const perms = await resolvePermissions(ctx);
|
|
||||||
|
|
||||||
expect(perms.server.read).toBe(false);
|
|
||||||
expect(perms.registry.read).toBe(false);
|
|
||||||
expect(perms.certificate.read).toBe(false);
|
|
||||||
expect(perms.destination.read).toBe(false);
|
|
||||||
expect(perms.notification.read).toBe(false);
|
|
||||||
expect(perms.auditLog.read).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("free-tier resources for member", () => {
|
|
||||||
it("member gets service.read=true", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
const perms = await resolvePermissions(ctx);
|
|
||||||
expect(perms.service.read).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member gets project.create=false without legacy override", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
const perms = await resolvePermissions(ctx);
|
|
||||||
expect(perms.project.create).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member gets project.create=true with canCreateProjects", async () => {
|
|
||||||
memberToReturn = mockMemberData("member", { canCreateProjects: true });
|
|
||||||
const perms = await resolvePermissions(ctx);
|
|
||||||
expect(perms.project.create).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member gets docker.read=false without legacy override", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
const perms = await resolvePermissions(ctx);
|
|
||||||
expect(perms.docker.read).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member gets docker.read=true with canAccessToDocker", async () => {
|
|
||||||
memberToReturn = mockMemberData("member", { canAccessToDocker: true });
|
|
||||||
const perms = await resolvePermissions(ctx);
|
|
||||||
expect(perms.docker.read).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("free-tier resources for owner", () => {
|
|
||||||
it("owner gets all free-tier permissions as true", async () => {
|
|
||||||
memberToReturn = mockMemberData("owner");
|
|
||||||
const perms = await resolvePermissions(ctx);
|
|
||||||
expect(perms.project.create).toBe(true);
|
|
||||||
expect(perms.project.delete).toBe(true);
|
|
||||||
expect(perms.service.create).toBe(true);
|
|
||||||
expect(perms.service.read).toBe(true);
|
|
||||||
expect(perms.service.delete).toBe(true);
|
|
||||||
expect(perms.docker.read).toBe(true);
|
|
||||||
expect(perms.traefikFiles.read).toBe(true);
|
|
||||||
expect(perms.traefikFiles.write).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const mockMemberData = (
|
|
||||||
role: string,
|
|
||||||
accessedServices: string[] = [],
|
|
||||||
accessedProjects: string[] = [],
|
|
||||||
) => ({
|
|
||||||
id: "member-1",
|
|
||||||
role,
|
|
||||||
userId: "user-1",
|
|
||||||
organizationId: "org-1",
|
|
||||||
accessedProjects,
|
|
||||||
accessedServices,
|
|
||||||
accessedEnvironments: [] as string[],
|
|
||||||
canCreateProjects: false,
|
|
||||||
canDeleteProjects: false,
|
|
||||||
canCreateServices: false,
|
|
||||||
canDeleteServices: false,
|
|
||||||
canCreateEnvironments: false,
|
|
||||||
canDeleteEnvironments: false,
|
|
||||||
canAccessToTraefikFiles: false,
|
|
||||||
canAccessToDocker: false,
|
|
||||||
canAccessToAPI: false,
|
|
||||||
canAccessToSSHKeys: false,
|
|
||||||
canAccessToGitProviders: false,
|
|
||||||
user: { id: "user-1", email: "test@test.com" },
|
|
||||||
});
|
|
||||||
|
|
||||||
let memberToReturn: ReturnType<typeof mockMemberData> =
|
|
||||||
mockMemberData("member");
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/db", () => ({
|
|
||||||
db: {
|
|
||||||
query: {
|
|
||||||
member: {
|
|
||||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
|
||||||
findMany: vi.fn(() => Promise.resolve([])),
|
|
||||||
},
|
|
||||||
organizationRole: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
findMany: vi.fn(() => Promise.resolve([])),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
|
||||||
hasValidLicense: vi.fn(() => Promise.resolve(false)),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { checkServicePermissionAndAccess, checkServiceAccess } = await import(
|
|
||||||
"@dokploy/server/services/permission"
|
|
||||||
);
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
user: { id: "user-1" },
|
|
||||||
session: { activeOrganizationId: "org-1" },
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("checkServicePermissionAndAccess", () => {
|
|
||||||
it("owner bypasses accessedServices check", async () => {
|
|
||||||
memberToReturn = mockMemberData("owner", []);
|
|
||||||
await expect(
|
|
||||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
|
||||||
deployment: ["read"],
|
|
||||||
}),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("admin bypasses accessedServices check", async () => {
|
|
||||||
memberToReturn = mockMemberData("admin", []);
|
|
||||||
await expect(
|
|
||||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
|
||||||
backup: ["create"],
|
|
||||||
}),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member with access to service passes", async () => {
|
|
||||||
memberToReturn = mockMemberData("member", ["service-123"]);
|
|
||||||
await expect(
|
|
||||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
|
||||||
deployment: ["read"],
|
|
||||||
}),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member WITHOUT access to service fails", async () => {
|
|
||||||
memberToReturn = mockMemberData("member", ["other-service"]);
|
|
||||||
await expect(
|
|
||||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
|
||||||
deployment: ["read"],
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("You don't have access to this service");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member with empty accessedServices fails", async () => {
|
|
||||||
memberToReturn = mockMemberData("member", []);
|
|
||||||
await expect(
|
|
||||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
|
||||||
domain: ["delete"],
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("You don't have access to this service");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("checkServiceAccess", () => {
|
|
||||||
it("member with service access passes read check", async () => {
|
|
||||||
memberToReturn = mockMemberData("member", ["app-1"]);
|
|
||||||
await expect(
|
|
||||||
checkServiceAccess(ctx, "app-1", "read"),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member without service access fails read check", async () => {
|
|
||||||
memberToReturn = mockMemberData("member", []);
|
|
||||||
await expect(checkServiceAccess(ctx, "app-1", "read")).rejects.toThrow(
|
|
||||||
"You don't have access to this service",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("owner bypasses all access checks", async () => {
|
|
||||||
memberToReturn = mockMemberData("owner", [], []);
|
|
||||||
await expect(
|
|
||||||
checkServiceAccess(ctx, "project-1", "create"),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -494,49 +494,4 @@ describe("processTemplate", () => {
|
|||||||
expect(result.mounts).toHaveLength(1);
|
expect(result.mounts).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isolated deployment config", () => {
|
|
||||||
it("should default to isolated=true when not specified", () => {
|
|
||||||
const template: CompleteTemplate = {
|
|
||||||
metadata: {} as any,
|
|
||||||
variables: {},
|
|
||||||
config: {
|
|
||||||
domains: [],
|
|
||||||
env: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(template.config.isolated).toBeUndefined();
|
|
||||||
// undefined !== false => isolatedDeployment = true
|
|
||||||
expect(template.config.isolated !== false).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should be isolated when isolated=true is explicitly set", () => {
|
|
||||||
const template: CompleteTemplate = {
|
|
||||||
metadata: {} as any,
|
|
||||||
variables: {},
|
|
||||||
config: {
|
|
||||||
isolated: true,
|
|
||||||
domains: [],
|
|
||||||
env: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(template.config.isolated !== false).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should disable isolated deployment when isolated=false", () => {
|
|
||||||
const template: CompleteTemplate = {
|
|
||||||
metadata: {} as any,
|
|
||||||
variables: {},
|
|
||||||
config: {
|
|
||||||
isolated: false,
|
|
||||||
domains: [],
|
|
||||||
env: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(template.config.isolated !== false).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ describe("helpers functions", () => {
|
|||||||
const domain = processValue("${domain}", {}, mockSchema);
|
const domain = processValue("${domain}", {}, mockSchema);
|
||||||
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
|
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
domain.endsWith(`${mockSchema.serverIp.replaceAll(".", "-")}.sslip.io`),
|
domain.endsWith(
|
||||||
|
`${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`,
|
||||||
|
),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,233 +0,0 @@
|
|||||||
import type { ApplicationNested, Domain } from "@dokploy/server";
|
|
||||||
import {
|
|
||||||
buildForwardAuthEnv,
|
|
||||||
createRouterConfig,
|
|
||||||
deriveBaseDomain,
|
|
||||||
deriveCookieSecret,
|
|
||||||
forwardAuthCallbackUrl,
|
|
||||||
forwardAuthMiddlewareName,
|
|
||||||
} from "@dokploy/server";
|
|
||||||
import { beforeAll, describe, expect, test } from "vitest";
|
|
||||||
|
|
||||||
const app = {
|
|
||||||
appName: "my-app",
|
|
||||||
redirects: [],
|
|
||||||
security: [],
|
|
||||||
} as unknown as ApplicationNested;
|
|
||||||
|
|
||||||
const baseDomain: Domain = {
|
|
||||||
applicationId: "app-1",
|
|
||||||
certificateType: "none",
|
|
||||||
createdAt: "",
|
|
||||||
domainId: "domain-1",
|
|
||||||
host: "app.example.com",
|
|
||||||
https: false,
|
|
||||||
path: null,
|
|
||||||
port: 3000,
|
|
||||||
customEntrypoint: null,
|
|
||||||
serviceName: "",
|
|
||||||
composeId: "",
|
|
||||||
customCertResolver: null,
|
|
||||||
domainType: "application",
|
|
||||||
uniqueConfigKey: 7,
|
|
||||||
previewDeploymentId: "",
|
|
||||||
internalPath: "/",
|
|
||||||
stripPath: false,
|
|
||||||
middlewares: null,
|
|
||||||
forwardAuthEnabled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("forwardAuthMiddlewareName", () => {
|
|
||||||
test("is stable and unique per app + uniqueConfigKey", () => {
|
|
||||||
expect(forwardAuthMiddlewareName("my-app", 7)).toBe(
|
|
||||||
"forward-auth-my-app-7",
|
|
||||||
);
|
|
||||||
expect(forwardAuthMiddlewareName("my-app", 7)).toBe(
|
|
||||||
forwardAuthMiddlewareName("my-app", 7),
|
|
||||||
);
|
|
||||||
expect(forwardAuthMiddlewareName("my-app", 7)).not.toBe(
|
|
||||||
forwardAuthMiddlewareName("my-app", 8),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createRouterConfig forward-auth wiring", () => {
|
|
||||||
test("does NOT add forward-auth middleware when no provider is linked", async () => {
|
|
||||||
const config = await createRouterConfig(app, baseDomain, "websecure");
|
|
||||||
expect(config.middlewares).not.toContain(
|
|
||||||
forwardAuthMiddlewareName("my-app", 7),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("adds forward-auth middleware when a provider is linked", async () => {
|
|
||||||
const domain: Domain = {
|
|
||||||
...baseDomain,
|
|
||||||
forwardAuthEnabled: true,
|
|
||||||
};
|
|
||||||
const config = await createRouterConfig(app, domain, "websecure");
|
|
||||||
expect(config.middlewares).toContain(
|
|
||||||
forwardAuthMiddlewareName("my-app", 7),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("forward-auth runs before custom domain middlewares", async () => {
|
|
||||||
const domain: Domain = {
|
|
||||||
...baseDomain,
|
|
||||||
forwardAuthEnabled: true,
|
|
||||||
middlewares: ["rate-limit@file"],
|
|
||||||
};
|
|
||||||
const config = await createRouterConfig(app, domain, "websecure");
|
|
||||||
const forwardAuthIdx = config.middlewares?.indexOf(
|
|
||||||
forwardAuthMiddlewareName("my-app", 7),
|
|
||||||
);
|
|
||||||
const customIdx = config.middlewares?.indexOf("rate-limit@file");
|
|
||||||
expect(forwardAuthIdx).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(customIdx).toBeGreaterThan(forwardAuthIdx as number);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("redirect-only web router does not get the forward-auth middleware", async () => {
|
|
||||||
const domain: Domain = {
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
forwardAuthEnabled: true,
|
|
||||||
};
|
|
||||||
const config = await createRouterConfig(app, domain, "web");
|
|
||||||
expect(config.middlewares).toContain("redirect-to-https");
|
|
||||||
expect(config.middlewares).not.toContain(
|
|
||||||
forwardAuthMiddlewareName("my-app", 7),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("buildForwardAuthEnv", () => {
|
|
||||||
const baseOptions = {
|
|
||||||
oidc: {
|
|
||||||
clientId: "client-123",
|
|
||||||
clientSecret: "secret-xyz",
|
|
||||||
issuer: "https://idp.example.com",
|
|
||||||
},
|
|
||||||
cookieSecret: "cookie-secret-value",
|
|
||||||
authDomain: "auth.acme.com",
|
|
||||||
baseDomain: ".acme.com",
|
|
||||||
authDomainHttps: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
test("emits the required oauth2-proxy OIDC env vars", () => {
|
|
||||||
const env = buildForwardAuthEnv(baseOptions);
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_PROVIDER=oidc");
|
|
||||||
expect(env).toContain(
|
|
||||||
"OAUTH2_PROXY_OIDC_ISSUER_URL=https://idp.example.com",
|
|
||||||
);
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_CLIENT_ID=client-123");
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_CLIENT_SECRET=secret-xyz");
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_COOKIE_SECRET=cookie-secret-value");
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_REVERSE_PROXY=true");
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:4180");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses the central auth domain for the single fixed callback", () => {
|
|
||||||
const env = buildForwardAuthEnv(baseOptions);
|
|
||||||
expect(env).toContain(
|
|
||||||
"OAUTH2_PROXY_REDIRECT_URL=https://auth.acme.com/oauth2/callback",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shares cookie + whitelist on the base domain (no per-app redeploy)", () => {
|
|
||||||
const env = buildForwardAuthEnv(baseOptions);
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_COOKIE_DOMAINS=.acme.com");
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_WHITELIST_DOMAINS=.acme.com");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("matches cookie Secure flag and callback scheme to https setting", () => {
|
|
||||||
const https = buildForwardAuthEnv(baseOptions);
|
|
||||||
expect(https).toContain("OAUTH2_PROXY_COOKIE_SECURE=true");
|
|
||||||
|
|
||||||
const http = buildForwardAuthEnv({
|
|
||||||
...baseOptions,
|
|
||||||
authDomainHttps: false,
|
|
||||||
});
|
|
||||||
expect(http).toContain("OAUTH2_PROXY_COOKIE_SECURE=false");
|
|
||||||
expect(http).toContain(
|
|
||||||
"OAUTH2_PROXY_REDIRECT_URL=http://auth.acme.com/oauth2/callback",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("allows unverified emails so OIDC providers don't 500 the callback", () => {
|
|
||||||
const env = buildForwardAuthEnv(baseOptions);
|
|
||||||
expect(env).toContain(
|
|
||||||
"OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL=true",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("defaults to any authenticated user and standard scopes", () => {
|
|
||||||
const env = buildForwardAuthEnv(baseOptions);
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_EMAIL_DOMAINS=*");
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_SCOPE=openid email profile");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("honors custom scopes and email domains", () => {
|
|
||||||
const env = buildForwardAuthEnv({
|
|
||||||
...baseOptions,
|
|
||||||
oidc: { ...baseOptions.oidc, scopes: ["openid", "groups"] },
|
|
||||||
emailDomains: ["acme.com", "corp.com"],
|
|
||||||
});
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_SCOPE=openid groups");
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_EMAIL_DOMAINS=acme.com,corp.com");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sets skip-discovery flag only when requested", () => {
|
|
||||||
const withoutSkip = buildForwardAuthEnv(baseOptions);
|
|
||||||
expect(withoutSkip).not.toContain("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
|
|
||||||
|
|
||||||
const withSkip = buildForwardAuthEnv({
|
|
||||||
...baseOptions,
|
|
||||||
oidc: { ...baseOptions.oidc, skipDiscovery: true },
|
|
||||||
});
|
|
||||||
expect(withSkip).toContain("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("deriveBaseDomain", () => {
|
|
||||||
test("strips the auth subdomain to the shared base", () => {
|
|
||||||
expect(deriveBaseDomain("auth.acme.com")).toBe(".acme.com");
|
|
||||||
expect(deriveBaseDomain("sso.apps.acme.com")).toBe(".apps.acme.com");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps a two-label apex as the base", () => {
|
|
||||||
expect(deriveBaseDomain("acme.com")).toBe(".acme.com");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("forwardAuthCallbackUrl", () => {
|
|
||||||
test("builds the single IdP callback per scheme", () => {
|
|
||||||
expect(forwardAuthCallbackUrl("auth.acme.com", true)).toBe(
|
|
||||||
"https://auth.acme.com/oauth2/callback",
|
|
||||||
);
|
|
||||||
expect(forwardAuthCallbackUrl("auth.acme.com", false)).toBe(
|
|
||||||
"http://auth.acme.com/oauth2/callback",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("deriveCookieSecret", () => {
|
|
||||||
beforeAll(() => {
|
|
||||||
process.env.BETTER_AUTH_SECRET = "test-root-secret";
|
|
||||||
});
|
|
||||||
|
|
||||||
test("is deterministic for the same salt (survives service updates)", () => {
|
|
||||||
expect(deriveCookieSecret(".acme.com")).toBe(
|
|
||||||
deriveCookieSecret(".acme.com"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("differs per salt", () => {
|
|
||||||
expect(deriveCookieSecret(".acme.com")).not.toBe(
|
|
||||||
deriveCookieSecret(".other.com"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("produces a 16-byte hex secret (oauth2-proxy requirement)", () => {
|
|
||||||
const secret = deriveCookieSecret(".acme.com");
|
|
||||||
expect(Buffer.from(secret, "hex")).toHaveLength(16);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -48,25 +48,9 @@ const baseSettings: WebServerSettings = {
|
|||||||
urlCallback: "",
|
urlCallback: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
whitelabelingConfig: {
|
|
||||||
appName: null,
|
|
||||||
appDescription: null,
|
|
||||||
logoUrl: null,
|
|
||||||
faviconUrl: null,
|
|
||||||
customCss: null,
|
|
||||||
loginLogoUrl: null,
|
|
||||||
supportUrl: null,
|
|
||||||
docsUrl: null,
|
|
||||||
errorPageTitle: null,
|
|
||||||
errorPageDescription: null,
|
|
||||||
metaTitle: null,
|
|
||||||
footerText: null,
|
|
||||||
},
|
|
||||||
cleanupCacheApplications: false,
|
cleanupCacheApplications: false,
|
||||||
cleanupCacheOnCompose: false,
|
cleanupCacheOnCompose: false,
|
||||||
cleanupCacheOnPreviews: false,
|
cleanupCacheOnPreviews: false,
|
||||||
remoteServersOnly: false,
|
|
||||||
enforceSSO: false,
|
|
||||||
createdAt: null,
|
createdAt: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,8 +145,6 @@ const baseDomain: Domain = {
|
|||||||
previewDeploymentId: "",
|
previewDeploymentId: "",
|
||||||
internalPath: "/",
|
internalPath: "/",
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
middlewares: null,
|
|
||||||
forwardAuthEnabled: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseRedirect: Redirect = {
|
const baseRedirect: Redirect = {
|
||||||
@@ -268,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 () => {
|
||||||
@@ -354,130 +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("stripPath and internalPath together: stripprefix must come before addprefix", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
path: "/public",
|
|
||||||
stripPath: true,
|
|
||||||
internalPath: "/app/v2",
|
|
||||||
},
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
const stripIndex = router.middlewares?.indexOf("stripprefix--1") ?? -1;
|
|
||||||
const addIndex = router.middlewares?.indexOf("addprefix--1") ?? -1;
|
|
||||||
|
|
||||||
expect(stripIndex).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(addIndex).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(stripIndex).toBeLessThan(addIndex);
|
|
||||||
});
|
|
||||||
|
|
||||||
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 () => {
|
||||||
|
|||||||
@@ -78,20 +78,4 @@ describe("readValidDirectory (path traversal)", () => {
|
|||||||
it("returns false for empty string (resolves to cwd)", () => {
|
it("returns false for empty string (resolves to cwd)", () => {
|
||||||
expect(readValidDirectory("")).toBe(false);
|
expect(readValidDirectory("")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns true for Next.js dynamic route paths with square brackets", () => {
|
|
||||||
expect(
|
|
||||||
readValidDirectory(
|
|
||||||
`${BASE}/applications/myapp/code/app/api/[id]/route.ts`,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
readValidDirectory(`${BASE}/applications/myapp/code/pages/[slug].tsx`),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
readValidDirectory(
|
|
||||||
`${BASE}/applications/myapp/code/app/[...catch]/page.tsx`,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,29 +16,17 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const optionalNumber = z
|
|
||||||
.union([z.string(), z.number()])
|
|
||||||
.transform((val) => (val === "" ? undefined : Number(val)))
|
|
||||||
.optional();
|
|
||||||
|
|
||||||
export const healthCheckFormSchema = z.object({
|
export const healthCheckFormSchema = z.object({
|
||||||
Test: z.array(z.string()).optional(),
|
Test: z.array(z.string()).optional(),
|
||||||
Interval: optionalNumber,
|
Interval: z.coerce.number().optional(),
|
||||||
Timeout: optionalNumber,
|
Timeout: z.coerce.number().optional(),
|
||||||
StartPeriod: optionalNumber,
|
StartPeriod: z.coerce.number().optional(),
|
||||||
Retries: optionalNumber,
|
Retries: z.coerce.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
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) => {
|
||||||
@@ -54,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]()
|
||||||
@@ -67,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]
|
||||||
@@ -118,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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -200,12 +185,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
Time between health checks (e.g., 10000000000 for 10 seconds)
|
Time between health checks (e.g., 10000000000 for 10 seconds)
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
type="number"
|
|
||||||
placeholder="10000000000"
|
|
||||||
{...field}
|
|
||||||
value={field.value ?? ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -222,12 +202,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
Maximum time to wait for health check response
|
Maximum time to wait for health check response
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
type="number"
|
|
||||||
placeholder="10000000000"
|
|
||||||
{...field}
|
|
||||||
value={field.value ?? ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -244,12 +219,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
Initial grace period before health checks begin
|
Initial grace period before health checks begin
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
type="number"
|
|
||||||
placeholder="10000000000"
|
|
||||||
{...field}
|
|
||||||
value={field.value ?? ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -267,12 +237,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
unhealthy
|
unhealthy
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input type="number" placeholder="3" {...field} />
|
||||||
type="number"
|
|
||||||
placeholder="3"
|
|
||||||
{...field}
|
|
||||||
value={field.value ?? ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -224,7 +220,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<FormLabel>Memory Limit</FormLabel>
|
<FormLabel>Memory Limit</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger>
|
||||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -263,7 +259,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<FormLabel>Memory Reservation</FormLabel>
|
<FormLabel>Memory Reservation</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger>
|
||||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -303,7 +299,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<FormLabel>CPU Limit</FormLabel>
|
<FormLabel>CPU Limit</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger>
|
||||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -343,7 +339,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<FormLabel>CPU Reservation</FormLabel>
|
<FormLabel>CPU Reservation</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger>
|
||||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -379,7 +375,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<FormLabel className="text-base">Ulimits</FormLabel>
|
<FormLabel className="text-base">Ulimits</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger>
|
||||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="max-w-xs">
|
<TooltipContent className="max-w-xs">
|
||||||
|
|||||||
@@ -15,17 +15,13 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShowTraefikConfig = ({ applicationId }: Props) => {
|
export const ShowTraefikConfig = ({ applicationId }: Props) => {
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
|
||||||
const canRead = permissions?.traefikFiles.read ?? false;
|
|
||||||
const { data, isPending } = api.application.readTraefikConfig.useQuery(
|
const { data, isPending } = api.application.readTraefikConfig.useQuery(
|
||||||
{
|
{
|
||||||
applicationId,
|
applicationId,
|
||||||
},
|
},
|
||||||
{ enabled: !!applicationId && canRead },
|
{ enabled: !!applicationId },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!canRead) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader className="flex flex-row justify-between">
|
<CardHeader className="flex flex-row justify-between">
|
||||||
|
|||||||
@@ -60,8 +60,6 @@ export const validateAndFormatYAML = (yamlText: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
|
||||||
const canWrite = permissions?.traefikFiles.write ?? false;
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
|
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
|
||||||
const { data, refetch } = api.application.readTraefikConfig.useQuery(
|
const { data, refetch } = api.application.readTraefikConfig.useQuery(
|
||||||
@@ -127,11 +125,9 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{canWrite && (
|
<DialogTrigger asChild>
|
||||||
<DialogTrigger asChild>
|
<Button isLoading={isPending}>Modify</Button>
|
||||||
<Button isLoading={isPending}>Modify</Button>
|
</DialogTrigger>
|
||||||
</DialogTrigger>
|
|
||||||
)}
|
|
||||||
<DialogContent className="sm:max-w-4xl">
|
<DialogContent className="sm:max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Update traefik config</DialogTitle>
|
<DialogTitle>Update traefik config</DialogTitle>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,33 +21,24 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShowVolumes = ({ id, type }: Props) => {
|
export const ShowVolumes = ({ id, type }: Props) => {
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
|
||||||
const canRead = permissions?.volume.read ?? false;
|
|
||||||
const canCreate = permissions?.volume.create ?? false;
|
|
||||||
const canDelete = permissions?.volume.delete ?? false;
|
|
||||||
|
|
||||||
if (!canRead) return null;
|
|
||||||
|
|
||||||
const queryMap = {
|
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">
|
||||||
@@ -59,7 +50,7 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canCreate && data && data?.mounts.length > 0 && (
|
{data && data?.mounts.length > 0 && (
|
||||||
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
||||||
Add Volume
|
Add Volume
|
||||||
</AddVolumes>
|
</AddVolumes>
|
||||||
@@ -72,11 +63,9 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
|||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
No volumes/mounts configured
|
No volumes/mounts configured
|
||||||
</span>
|
</span>
|
||||||
{canCreate && (
|
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
||||||
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
Add Volume
|
||||||
Add Volume
|
</AddVolumes>
|
||||||
</AddVolumes>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col pt-2 gap-4">
|
<div className="flex flex-col pt-2 gap-4">
|
||||||
@@ -141,42 +130,38 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-1">
|
<div className="flex flex-row gap-1">
|
||||||
{canCreate && (
|
<UpdateVolume
|
||||||
<UpdateVolume
|
mountId={mount.mountId}
|
||||||
mountId={mount.mountId}
|
type={mount.type}
|
||||||
type={mount.type}
|
refetch={refetch}
|
||||||
refetch={refetch}
|
serviceType={type}
|
||||||
serviceType={type}
|
/>
|
||||||
/>
|
<DialogAction
|
||||||
)}
|
title="Delete Volume"
|
||||||
{canDelete && (
|
description="Are you sure you want to delete this volume?"
|
||||||
<DialogAction
|
type="destructive"
|
||||||
title="Delete Volume"
|
onClick={async () => {
|
||||||
description="Are you sure you want to delete this volume?"
|
await deleteVolume({
|
||||||
type="destructive"
|
mountId: mount.mountId,
|
||||||
onClick={async () => {
|
})
|
||||||
await deleteVolume({
|
.then(() => {
|
||||||
mountId: mount.mountId,
|
refetch();
|
||||||
|
toast.success("Volume deleted successfully");
|
||||||
})
|
})
|
||||||
.then(() => {
|
.catch(() => {
|
||||||
refetch();
|
toast.error("Error deleting volume");
|
||||||
toast.success("Volume deleted successfully");
|
});
|
||||||
})
|
}}
|
||||||
.catch(() => {
|
>
|
||||||
toast.error("Error deleting volume");
|
<Button
|
||||||
});
|
variant="ghost"
|
||||||
}}
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10"
|
||||||
|
isLoading={isRemoving}
|
||||||
>
|
>
|
||||||
<Button
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
variant="ghost"
|
</Button>
|
||||||
size="icon"
|
</DialogAction>
|
||||||
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>
|
||||||
|
|||||||
@@ -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,7 +1,6 @@
|
|||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
import { Check, Copy, Loader2 } from "lucide-react";
|
import { Check, Copy, Loader2 } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { AnalyzeLogs } from "@/components/dashboard/docker/logs/analyze-logs";
|
|
||||||
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 { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
@@ -166,7 +165,6 @@ export const ShowDeployment = ({
|
|||||||
<Copy className="h-3.5 w-3.5" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<AnalyzeLogs logs={filteredLogs} context="build" />
|
|
||||||
|
|
||||||
{serverId && (
|
{serverId && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import copy from "copy-to-clipboard";
|
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Clock,
|
Clock,
|
||||||
Copy,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
@@ -99,12 +97,6 @@ export const ShowDeployments = ({
|
|||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const webhookUrl = useMemo(
|
|
||||||
() =>
|
|
||||||
`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`,
|
|
||||||
[url, refreshToken, type],
|
|
||||||
);
|
|
||||||
|
|
||||||
const MAX_DESCRIPTION_LENGTH = 200;
|
const MAX_DESCRIPTION_LENGTH = 200;
|
||||||
|
|
||||||
const truncateDescription = (description: string): string => {
|
const truncateDescription = (description: string): string => {
|
||||||
@@ -232,27 +224,11 @@ export const ShowDeployments = ({
|
|||||||
<div className="flex flex-row items-center gap-2 flex-wrap">
|
<div className="flex flex-row items-center gap-2 flex-wrap">
|
||||||
<span>Webhook URL: </span>
|
<span>Webhook URL: </span>
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
<Badge
|
<span className="break-all text-muted-foreground">
|
||||||
role="button"
|
{`${url}/api/deploy${
|
||||||
tabIndex={0}
|
type === "compose" ? "/compose" : ""
|
||||||
aria-label="Copy webhook URL to clipboard"
|
}/${refreshToken}`}
|
||||||
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer whitespace-normal break-all"
|
</span>
|
||||||
variant="outline"
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
copy(webhookUrl);
|
|
||||||
toast.success("Copied to clipboard.");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
copy(webhookUrl);
|
|
||||||
toast.success("Copied to clipboard.");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{webhookUrl}
|
|
||||||
<Copy className="h-4 w-4 ml-2" />
|
|
||||||
</Badge>
|
|
||||||
{(type === "application" || type === "compose") && (
|
{(type === "application" || type === "compose") && (
|
||||||
<RefreshToken id={id} type={type} />
|
<RefreshToken id={id} type={type} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 { DnsHelperModal } from "./dns-helper-modal";
|
|
||||||
import { AddDomain } from "./handle-domain";
|
|
||||||
import type { ValidationStates } from "./show-domains";
|
|
||||||
|
|
||||||
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("sslip.io") && (
|
|
||||||
<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("sslip.io") && (
|
|
||||||
<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,24 +196,20 @@ 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");
|
||||||
const isTraefikMeDomain = host?.includes("sslip.io") || false;
|
const isTraefikMeDomain = host?.includes("traefik.me") || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -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);
|
||||||
@@ -513,7 +490,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
{!canGenerateTraefikMeDomains &&
|
{!canGenerateTraefikMeDomains &&
|
||||||
field.value.includes("sslip.io") && (
|
field.value.includes("traefik.me") && (
|
||||||
<AlertBlock type="warning">
|
<AlertBlock type="warning">
|
||||||
You need to set an IP address in your{" "}
|
You need to set an IP address in your{" "}
|
||||||
<Link
|
<Link
|
||||||
@@ -524,12 +501,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||||
: "Web Server -> Server -> Update Server IP"}
|
: "Web Server -> Server -> Update Server IP"}
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
to make your sslip.io domain work.
|
to make your traefik.me domain work.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
)}
|
)}
|
||||||
{isTraefikMeDomain && (
|
{isTraefikMeDomain && (
|
||||||
<AlertBlock type="info">
|
<AlertBlock type="info">
|
||||||
<strong>Note:</strong> sslip.io is a public HTTP
|
<strong>Note:</strong> traefik.me is a public HTTP
|
||||||
service and does not support SSL/HTTPS. HTTPS and
|
service and does not support SSL/HTTPS. HTTPS and
|
||||||
certificate options will not have any effect.
|
certificate options will not have any effect.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
@@ -567,7 +544,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
sideOffset={5}
|
sideOffset={5}
|
||||||
className="max-w-[10rem]"
|
className="max-w-[10rem]"
|
||||||
>
|
>
|
||||||
<p>Generate sslip.io domain</p>
|
<p>Generate traefik.me domain</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -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 domain
|
|
||||||
<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 type="button">
|
|
||||||
<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,147 +0,0 @@
|
|||||||
import { ShieldCheck } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
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 { Switch } from "@/components/ui/switch";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
domainId: string;
|
|
||||||
applicationId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HandleForwardAuth = ({ domainId, applicationId }: Props) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
const { data: haveValidLicense } =
|
|
||||||
api.licenseKey.haveValidLicenseKey.useQuery();
|
|
||||||
|
|
||||||
const utils = api.useUtils();
|
|
||||||
|
|
||||||
const { data: status } = api.forwardAuth.status.useQuery(
|
|
||||||
{ domainId },
|
|
||||||
{ enabled: isOpen },
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutateAsync: enable, isPending: isEnabling } =
|
|
||||||
api.forwardAuth.enable.useMutation();
|
|
||||||
const { mutateAsync: disable, isPending: isDisabling } =
|
|
||||||
api.forwardAuth.disable.useMutation();
|
|
||||||
|
|
||||||
if (!haveValidLicense) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isEnabled = !!status?.enabled;
|
|
||||||
const isPending = isEnabling || isDisabling;
|
|
||||||
|
|
||||||
const refresh = async () => {
|
|
||||||
await utils.forwardAuth.status.invalidate({ domainId });
|
|
||||||
await utils.domain.byApplicationId.invalidate({ applicationId });
|
|
||||||
await utils.application.readTraefikConfig.invalidate({ applicationId });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggle = async (next: boolean) => {
|
|
||||||
try {
|
|
||||||
if (next) {
|
|
||||||
await enable({ domainId });
|
|
||||||
toast.success("SSO authentication enabled for this domain");
|
|
||||||
} else {
|
|
||||||
await disable({ domainId });
|
|
||||||
toast.success("SSO authentication disabled for this domain");
|
|
||||||
}
|
|
||||||
await refresh();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Error updating SSO authentication",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-emerald-500/10"
|
|
||||||
title="SSO authentication"
|
|
||||||
>
|
|
||||||
<ShieldCheck
|
|
||||||
className={`size-4 ${
|
|
||||||
isEnabled
|
|
||||||
? "text-emerald-500"
|
|
||||||
: "text-primary group-hover:text-emerald-500"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>SSO Authentication</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Require visitors to authenticate against your identity provider
|
|
||||||
before reaching this application.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<AlertBlock type="warning">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium">Requirements</span>
|
|
||||||
<ol className="list-decimal pl-4 text-sm">
|
|
||||||
<li>
|
|
||||||
The authentication proxy container must be deployed and running
|
|
||||||
on this app's server. Configure it under{" "}
|
|
||||||
<span className="font-medium">
|
|
||||||
Settings → SSO → Application Authentication
|
|
||||||
</span>
|
|
||||||
.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
This domain must share the same base domain as the
|
|
||||||
authentication domain (e.g. <code>app.acme.com</code> and{" "}
|
|
||||||
<code>auth.acme.com</code>).
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</AlertBlock>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between rounded-lg border p-4 mt-2">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
Protect this domain with SSO
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{isEnabled
|
|
||||||
? "Visitors must log in via your identity provider."
|
|
||||||
: "The domain is publicly accessible."}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={isEnabled}
|
|
||||||
disabled={isPending}
|
|
||||||
onCheckedChange={handleToggle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setIsOpen(false)}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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,10 +30,8 @@ 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";
|
||||||
import { HandleForwardAuth } from "./handle-forward-auth";
|
|
||||||
|
|
||||||
export type ValidationState = {
|
export type ValidationState = {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -81,9 +50,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShowDomains = ({ id, type }: Props) => {
|
export const ShowDomains = ({ id, type }: Props) => {
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
|
||||||
const canCreateDomain = permissions?.domain.create ?? false;
|
|
||||||
const canDeleteDomain = permissions?.domain.delete ?? false;
|
|
||||||
const { data: application } =
|
const { data: application } =
|
||||||
type === "application"
|
type === "application"
|
||||||
? api.application.one.useQuery(
|
? api.application.one.useQuery(
|
||||||
@@ -105,19 +71,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 {
|
||||||
@@ -147,16 +100,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,
|
||||||
@@ -194,37 +137,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">
|
||||||
@@ -236,32 +148,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 && (
|
{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>
|
||||||
@@ -280,131 +173,13 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
To access the application it is required to set at least 1
|
To access the application it is required to set at least 1
|
||||||
domain
|
domain
|
||||||
</span>
|
</span>
|
||||||
{canCreateDomain && (
|
<div className="flex flex-row gap-4 flex-wrap">
|
||||||
<div className="flex flex-row gap-4 flex-wrap">
|
<AddDomain id={id} type={type}>
|
||||||
<AddDomain id={id} type={type}>
|
<Button>
|
||||||
<Button>
|
<GlobeIcon className="size-4" /> Add Domain
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
</Button>
|
||||||
</Button>
|
</AddDomain>
|
||||||
</AddDomain>
|
|
||||||
</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>
|
||||||
<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>
|
||||||
) : (
|
) : (
|
||||||
<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] ">
|
||||||
@@ -426,7 +201,7 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{!item.host.includes("sslip.io") && (
|
{!item.host.includes("traefik.me") && (
|
||||||
<DnsHelperModal
|
<DnsHelperModal
|
||||||
domain={{
|
domain={{
|
||||||
host: item.host,
|
host: item.host,
|
||||||
@@ -439,57 +214,47 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{canCreateDomain && (
|
<AddDomain
|
||||||
<AddDomain
|
id={id}
|
||||||
id={id}
|
type={type}
|
||||||
type={type}
|
domainId={item.domainId}
|
||||||
domainId={item.domainId}
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-blue-500/10"
|
||||||
>
|
>
|
||||||
<Button
|
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||||
variant="ghost"
|
</Button>
|
||||||
size="icon"
|
</AddDomain>
|
||||||
className="group hover:bg-blue-500/10"
|
<DialogAction
|
||||||
>
|
title="Delete Domain"
|
||||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
description="Are you sure you want to delete this domain?"
|
||||||
</Button>
|
type="destructive"
|
||||||
</AddDomain>
|
onClick={async () => {
|
||||||
)}
|
await deleteDomain({
|
||||||
{canCreateDomain && type === "application" && (
|
domainId: item.domainId,
|
||||||
<HandleForwardAuth
|
})
|
||||||
domainId={item.domainId}
|
.then((_data) => {
|
||||||
applicationId={id}
|
refetch();
|
||||||
/>
|
toast.success(
|
||||||
)}
|
"Domain deleted successfully",
|
||||||
{canDeleteDomain && (
|
);
|
||||||
<DialogAction
|
|
||||||
title="Delete Domain"
|
|
||||||
description="Are you sure you want to delete this domain?"
|
|
||||||
type="destructive"
|
|
||||||
onClick={async () => {
|
|
||||||
await deleteDomain({
|
|
||||||
domainId: item.domainId,
|
|
||||||
})
|
})
|
||||||
.then((_data) => {
|
.catch(() => {
|
||||||
refetch();
|
toast.error("Error deleting domain");
|
||||||
toast.success(
|
});
|
||||||
"Domain deleted successfully",
|
}}
|
||||||
);
|
>
|
||||||
})
|
<Button
|
||||||
.catch(() => {
|
variant="ghost"
|
||||||
toast.error("Error deleting domain");
|
size="icon"
|
||||||
});
|
className="group hover:bg-red-500/10"
|
||||||
}}
|
isLoading={isRemoving}
|
||||||
>
|
>
|
||||||
<Button
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
variant="ghost"
|
</Button>
|
||||||
size="icon"
|
</DialogAction>
|
||||||
className="group hover:bg-red-500/10"
|
|
||||||
isLoading={isRemoving}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full break-all">
|
<div className="w-full break-all">
|
||||||
@@ -567,22 +332,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>
|
||||||
|
|||||||
@@ -36,19 +36,16 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShowEnvironment = ({ id, type }: Props) => {
|
export const ShowEnvironment = ({ id, type }: Props) => {
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
|
||||||
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,17 +53,16 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
||||||
|
|
||||||
const mutationMap = {
|
const mutationMap = {
|
||||||
compose: () => api.compose.saveEnvironment.useMutation(),
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
libsql: () => api.libsql.saveEnvironment.useMutation(),
|
redis: () => api.redis.update.useMutation(),
|
||||||
mariadb: () => api.mariadb.saveEnvironment.useMutation(),
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
mongo: () => api.mongo.saveEnvironment.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
mysql: () => api.mysql.saveEnvironment.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
postgres: () => api.postgres.saveEnvironment.useMutation(),
|
compose: () => api.compose.update.useMutation(),
|
||||||
redis: () => api.redis.saveEnvironment.useMutation(),
|
|
||||||
};
|
};
|
||||||
const { mutateAsync, isPending } = mutationMap[type]
|
const { mutateAsync, isPending } = mutationMap[type]
|
||||||
? mutationMap[type]()
|
? mutationMap[type]()
|
||||||
: api.mongo.saveEnvironment.useMutation();
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
const form = useForm<EnvironmentSchema>({
|
const form = useForm<EnvironmentSchema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -89,13 +85,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 +111,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)();
|
||||||
}
|
}
|
||||||
@@ -190,27 +185,25 @@ PORT=3000
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{canWrite && (
|
<div className="flex flex-row justify-end gap-2">
|
||||||
<div className="flex flex-row justify-end gap-2">
|
{hasChanges && (
|
||||||
{hasChanges && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleCancel}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
isLoading={isPending}
|
type="button"
|
||||||
className="w-fit"
|
variant="outline"
|
||||||
type="submit"
|
onClick={handleCancel}
|
||||||
disabled={!hasChanges}
|
|
||||||
>
|
>
|
||||||
Save
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
)}
|
<Button
|
||||||
|
isLoading={isPending}
|
||||||
|
className="w-fit"
|
||||||
|
type="submit"
|
||||||
|
disabled={!hasChanges}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -31,8 +31,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShowEnvironment = ({ applicationId }: Props) => {
|
export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
|
||||||
const canWrite = permissions?.envVars.write ?? false;
|
|
||||||
const { mutateAsync, isPending } =
|
const { mutateAsync, isPending } =
|
||||||
api.application.saveEnvironment.useMutation();
|
api.application.saveEnvironment.useMutation();
|
||||||
|
|
||||||
@@ -106,7 +104,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)();
|
||||||
}
|
}
|
||||||
@@ -203,30 +201,27 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
<Switch
|
<Switch
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
disabled={!canWrite}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{canWrite && (
|
<div className="flex flex-row justify-end gap-2">
|
||||||
<div className="flex flex-row justify-end gap-2">
|
{hasChanges && (
|
||||||
{hasChanges && (
|
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
Cancel
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
isLoading={isPending}
|
|
||||||
className="w-fit"
|
|
||||||
type="submit"
|
|
||||||
disabled={!hasChanges}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
)}
|
<Button
|
||||||
|
isLoading={isPending}
|
||||||
|
className="w-fit"
|
||||||
|
type="submit"
|
||||||
|
disabled={!hasChanges}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, 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";
|
||||||
@@ -58,10 +57,7 @@ const BitbucketProviderSchema = z.object({
|
|||||||
slug: z.string().optional(),
|
slug: z.string().optional(),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch is required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch is required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().optional(),
|
enableSubmodules: z.boolean().optional(),
|
||||||
@@ -420,8 +416,10 @@ export const SaveBitbucketProvider = ({ applicationId }: 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>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
|
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -42,10 +41,7 @@ const GitProviderSchema = z.object({
|
|||||||
repositoryURL: z.string().min(1, {
|
repositoryURL: z.string().min(1, {
|
||||||
message: "Repository URL is required",
|
message: "Repository URL is required",
|
||||||
}),
|
}),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
sshKey: z.string().optional(),
|
sshKey: z.string().optional(),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
@@ -59,7 +55,7 @@ interface Props {
|
|||||||
|
|
||||||
export const SaveGitProvider = ({ applicationId }: Props) => {
|
export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||||
const { data: sshKeys } = api.sshKey.allForApps.useQuery();
|
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { mutateAsync, isPending } =
|
const { mutateAsync, isPending } =
|
||||||
@@ -111,103 +107,110 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 items-start">
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
<FormField
|
className="flex flex-col gap-4"
|
||||||
control={form.control}
|
>
|
||||||
name="repositoryURL"
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
render={({ field }) => (
|
<div className="flex items-end col-span-2 gap-4">
|
||||||
<FormItem className="col-span-2 lg:col-span-3">
|
<div className="grow">
|
||||||
<div className="flex items-center justify-between h-5">
|
<FormField
|
||||||
<FormLabel>Repository URL</FormLabel>
|
control={form.control}
|
||||||
{field.value?.startsWith("https://") && (
|
name="repositoryURL"
|
||||||
<Link
|
render={({ field }) => (
|
||||||
href={field.value}
|
<FormItem>
|
||||||
target="_blank"
|
<div className="flex items-center justify-between">
|
||||||
rel="noopener noreferrer"
|
<FormLabel>Repository URL</FormLabel>
|
||||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
{field.value?.startsWith("https://") && (
|
||||||
>
|
<Link
|
||||||
<GitIcon className="h-4 w-4" />
|
href={field.value}
|
||||||
<span>View Repository</span>
|
target="_blank"
|
||||||
</Link>
|
rel="noopener noreferrer"
|
||||||
)}
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||||
</div>
|
>
|
||||||
<FormControl>
|
<GitIcon className="h-4 w-4" />
|
||||||
<Input placeholder="Repository URL" {...field} />
|
<span>View Repository</span>
|
||||||
</FormControl>
|
</Link>
|
||||||
<FormMessage />
|
)}
|
||||||
</FormItem>
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Repository URL" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{sshKeys && sshKeys.length > 0 ? (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="sshKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="basis-40">
|
||||||
|
<FormLabel className="w-full inline-flex justify-between">
|
||||||
|
SSH Key
|
||||||
|
<LockIcon className="size-4 text-muted-foreground" />
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
key={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a key" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{sshKeys?.map((sshKey) => (
|
||||||
|
<SelectItem
|
||||||
|
key={sshKey.sshKeyId}
|
||||||
|
value={sshKey.sshKeyId}
|
||||||
|
>
|
||||||
|
{sshKey.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => router.push("/dashboard/settings/ssh-keys")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<KeyRoundIcon className="size-4" /> Add SSH Key
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
/>
|
</div>
|
||||||
{sshKeys && sshKeys.length > 0 ? (
|
<div className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="sshKey"
|
name="branch"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="col-span-2 lg:col-span-1">
|
<FormItem>
|
||||||
<FormLabel className="w-full inline-flex justify-between">
|
<FormLabel>Branch</FormLabel>
|
||||||
SSH Key
|
|
||||||
<LockIcon className="size-4 text-muted-foreground" />
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Input placeholder="Branch" {...field} />
|
||||||
key={field.value}
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
value={field.value}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a key" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{sshKeys?.map((sshKey) => (
|
|
||||||
<SelectItem
|
|
||||||
key={sshKey.sshKeyId}
|
|
||||||
value={sshKey.sshKeyId}
|
|
||||||
>
|
|
||||||
{sshKey.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
<SelectItem value="none">None</SelectItem>
|
|
||||||
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => router.push("/dashboard/settings/ssh-keys")}
|
|
||||||
type="button"
|
|
||||||
className="col-span-2 lg:col-span-1 lg:mt-7"
|
|
||||||
>
|
|
||||||
<KeyRoundIcon className="size-4" /> Add SSH Key
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="branch"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="col-span-2">
|
|
||||||
<FormLabel>Branch</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Branch" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="buildPath"
|
name="buildPath"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="col-span-2">
|
<FormItem>
|
||||||
<FormLabel>Build Path</FormLabel>
|
<FormLabel>Build Path</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="/" {...field} />
|
<Input placeholder="/" {...field} />
|
||||||
@@ -220,13 +223,15 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="watchPaths"
|
name="watchPaths"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="col-span-2 lg:col-span-4">
|
<FormItem className="md:col-span-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<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 className="max-w-[300px]">
|
<TooltipContent className="max-w-[300px]">
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -73,10 +72,7 @@ const GiteaProviderSchema = z.object({
|
|||||||
owner: z.string().min(1, "Owner is required"),
|
owner: z.string().min(1, "Owner is required"),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch is required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch is required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
giteaId: z.string().min(1, "Gitea Provider is required"),
|
giteaId: z.string().min(1, "Gitea Provider is required"),
|
||||||
watchPaths: z.array(z.string()).default([]),
|
watchPaths: z.array(z.string()).default([]),
|
||||||
enableSubmodules: z.boolean().optional(),
|
enableSubmodules: z.boolean().optional(),
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -56,10 +55,7 @@ const GithubProviderSchema = z.object({
|
|||||||
owner: z.string().min(1, "Owner is required"),
|
owner: z.string().min(1, "Owner is required"),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch is required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch is required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
githubId: z.string().min(1, "Github Provider is required"),
|
githubId: z.string().min(1, "Github Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
triggerType: z.enum(["push", "tag"]).default("push"),
|
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -59,10 +58,7 @@ const GitlabProviderSchema = z.object({
|
|||||||
id: z.number().nullable(),
|
id: z.number().nullable(),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch is required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch is required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
|
|||||||
@@ -30,9 +30,6 @@ interface Props {
|
|||||||
|
|
||||||
export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
|
||||||
const canDeploy = permissions?.deployment.create ?? false;
|
|
||||||
const canUpdateService = permissions?.service.create ?? false;
|
|
||||||
const { data, refetch } = api.application.one.useQuery(
|
const { data, refetch } = api.application.one.useQuery(
|
||||||
{
|
{
|
||||||
applicationId,
|
applicationId,
|
||||||
@@ -58,137 +55,130 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">Deploy Settings</CardTitle>
|
<CardTitle className="text-xl">Deploy Settings</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-2 lg:flex lg:flex-row lg:flex-wrap gap-4">
|
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||||
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
||||||
{canDeploy && (
|
<DialogAction
|
||||||
<DialogAction
|
title="Deploy Application"
|
||||||
title="Deploy Application"
|
description="Are you sure you want to deploy this application?"
|
||||||
description="Are you sure you want to deploy this application?"
|
type="default"
|
||||||
type="default"
|
onClick={async () => {
|
||||||
onClick={async () => {
|
await deploy({
|
||||||
await deploy({
|
applicationId: applicationId,
|
||||||
applicationId: applicationId,
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Application deployed successfully");
|
||||||
|
refetch();
|
||||||
|
router.push(
|
||||||
|
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.then(() => {
|
.catch(() => {
|
||||||
toast.success("Application deployed successfully");
|
toast.error("Error deploying application");
|
||||||
refetch();
|
});
|
||||||
router.push(
|
}}
|
||||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
|
>
|
||||||
);
|
<Button
|
||||||
})
|
variant="default"
|
||||||
.catch(() => {
|
isLoading={data?.applicationStatus === "running"}
|
||||||
toast.error("Error deploying application");
|
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Button
|
<Tooltip>
|
||||||
variant="default"
|
<TooltipTrigger asChild>
|
||||||
isLoading={data?.applicationStatus === "running"}
|
<div className="flex items-center">
|
||||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
<Rocket className="size-4 mr-1" />
|
||||||
>
|
Deploy
|
||||||
<Tooltip>
|
</div>
|
||||||
<TooltipTrigger asChild>
|
</TooltipTrigger>
|
||||||
<div className="flex items-center">
|
<TooltipPrimitive.Portal>
|
||||||
<Rocket className="size-4 mr-1" />
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
Deploy
|
<p>
|
||||||
</div>
|
Downloads the source code and performs a complete build
|
||||||
</TooltipTrigger>
|
</p>
|
||||||
<TooltipPrimitive.Portal>
|
</TooltipContent>
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
</TooltipPrimitive.Portal>
|
||||||
<p>
|
</Tooltip>
|
||||||
Downloads the source code and performs a complete
|
</Button>
|
||||||
build
|
</DialogAction>
|
||||||
</p>
|
<DialogAction
|
||||||
</TooltipContent>
|
title="Reload Application"
|
||||||
</TooltipPrimitive.Portal>
|
description="Are you sure you want to reload this application?"
|
||||||
</Tooltip>
|
type="default"
|
||||||
</Button>
|
onClick={async () => {
|
||||||
</DialogAction>
|
await reload({
|
||||||
)}
|
applicationId: applicationId,
|
||||||
{canDeploy && (
|
appName: data?.appName || "",
|
||||||
<DialogAction
|
})
|
||||||
title="Reload Application"
|
.then(() => {
|
||||||
description="Are you sure you want to reload this application?"
|
toast.success("Application reloaded successfully");
|
||||||
type="default"
|
refetch();
|
||||||
onClick={async () => {
|
|
||||||
await reload({
|
|
||||||
applicationId: applicationId,
|
|
||||||
appName: data?.appName || "",
|
|
||||||
})
|
})
|
||||||
.then(() => {
|
.catch(() => {
|
||||||
toast.success("Application reloaded successfully");
|
toast.error("Error reloading application");
|
||||||
refetch();
|
});
|
||||||
})
|
}}
|
||||||
.catch(() => {
|
>
|
||||||
toast.error("Error reloading application");
|
<Button
|
||||||
});
|
variant="secondary"
|
||||||
}}
|
isLoading={isReloading}
|
||||||
|
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
<Button
|
<Tooltip>
|
||||||
variant="secondary"
|
<TooltipTrigger asChild>
|
||||||
isLoading={isReloading}
|
<div className="flex items-center">
|
||||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
<RefreshCcw className="size-4 mr-1" />
|
||||||
>
|
Reload
|
||||||
<Tooltip>
|
</div>
|
||||||
<TooltipTrigger asChild>
|
</TooltipTrigger>
|
||||||
<div className="flex items-center">
|
<TooltipPrimitive.Portal>
|
||||||
<RefreshCcw className="size-4 mr-1" />
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
Reload
|
<p>Reload the application without rebuilding it</p>
|
||||||
</div>
|
</TooltipContent>
|
||||||
</TooltipTrigger>
|
</TooltipPrimitive.Portal>
|
||||||
<TooltipPrimitive.Portal>
|
</Tooltip>
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
</Button>
|
||||||
<p>Reload the application without rebuilding it</p>
|
</DialogAction>
|
||||||
</TooltipContent>
|
<DialogAction
|
||||||
</TooltipPrimitive.Portal>
|
title="Rebuild Application"
|
||||||
</Tooltip>
|
description="Are you sure you want to rebuild this application?"
|
||||||
</Button>
|
type="default"
|
||||||
</DialogAction>
|
onClick={async () => {
|
||||||
)}
|
await redeploy({
|
||||||
{canDeploy && (
|
applicationId: applicationId,
|
||||||
<DialogAction
|
})
|
||||||
title="Rebuild Application"
|
.then(() => {
|
||||||
description="Are you sure you want to rebuild this application?"
|
toast.success("Application rebuilt successfully");
|
||||||
type="default"
|
refetch();
|
||||||
onClick={async () => {
|
|
||||||
await redeploy({
|
|
||||||
applicationId: applicationId,
|
|
||||||
})
|
})
|
||||||
.then(() => {
|
.catch(() => {
|
||||||
toast.success("Application rebuilt successfully");
|
toast.error("Error rebuilding application");
|
||||||
refetch();
|
});
|
||||||
})
|
}}
|
||||||
.catch(() => {
|
>
|
||||||
toast.error("Error rebuilding application");
|
<Button
|
||||||
});
|
variant="secondary"
|
||||||
}}
|
isLoading={data?.applicationStatus === "running"}
|
||||||
|
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
<Button
|
<Tooltip>
|
||||||
variant="secondary"
|
<TooltipTrigger asChild>
|
||||||
isLoading={data?.applicationStatus === "running"}
|
<div className="flex items-center">
|
||||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
<Hammer className="size-4 mr-1" />
|
||||||
>
|
Rebuild
|
||||||
<Tooltip>
|
</div>
|
||||||
<TooltipTrigger asChild>
|
</TooltipTrigger>
|
||||||
<div className="flex items-center">
|
<TooltipPrimitive.Portal>
|
||||||
<Hammer className="size-4 mr-1" />
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
Rebuild
|
<p>
|
||||||
</div>
|
Only rebuilds the application without downloading new
|
||||||
</TooltipTrigger>
|
code
|
||||||
<TooltipPrimitive.Portal>
|
</p>
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
</TooltipContent>
|
||||||
<p>
|
</TooltipPrimitive.Portal>
|
||||||
Only rebuilds the application without downloading new
|
</Tooltip>
|
||||||
code
|
</Button>
|
||||||
</p>
|
</DialogAction>
|
||||||
</TooltipContent>
|
|
||||||
</TooltipPrimitive.Portal>
|
|
||||||
</Tooltip>
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canDeploy && data?.applicationStatus === "idle" ? (
|
{data?.applicationStatus === "idle" ? (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Start Application"
|
title="Start Application"
|
||||||
description="Are you sure you want to start this application?"
|
description="Are you sure you want to start this application?"
|
||||||
@@ -229,7 +219,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
) : canDeploy ? (
|
) : (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Stop Application"
|
title="Stop Application"
|
||||||
description="Are you sure you want to stop this application?"
|
description="Are you sure you want to stop this application?"
|
||||||
@@ -266,7 +256,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
) : null}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<DockerTerminalModal
|
<DockerTerminalModal
|
||||||
appName={data?.appName || ""}
|
appName={data?.appName || ""}
|
||||||
@@ -274,59 +264,55 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2 col-span-2"
|
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
<Terminal className="size-4 mr-1" />
|
<Terminal className="size-4 mr-1" />
|
||||||
Open Terminal
|
Open Terminal
|
||||||
</Button>
|
</Button>
|
||||||
</DockerTerminalModal>
|
</DockerTerminalModal>
|
||||||
{canUpdateService && (
|
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||||
<div className="flex flex-row items-center gap-2 justify-between rounded-md px-4 py-2 border col-span-2 md:col-span-1">
|
<span className="text-sm font-medium">Autodeploy</span>
|
||||||
<span className="text-sm font-medium">Autodeploy</span>
|
<Switch
|
||||||
<Switch
|
aria-label="Toggle autodeploy"
|
||||||
aria-label="Toggle autodeploy"
|
checked={data?.autoDeploy || false}
|
||||||
checked={data?.autoDeploy || false}
|
onCheckedChange={async (enabled) => {
|
||||||
onCheckedChange={async (enabled) => {
|
await update({
|
||||||
await update({
|
applicationId,
|
||||||
applicationId,
|
autoDeploy: enabled,
|
||||||
autoDeploy: enabled,
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success("Auto Deploy Updated");
|
||||||
|
await refetch();
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.catch(() => {
|
||||||
toast.success("Auto Deploy Updated");
|
toast.error("Error updating Auto Deploy");
|
||||||
await refetch();
|
});
|
||||||
})
|
}}
|
||||||
.catch(() => {
|
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||||
toast.error("Error updating Auto Deploy");
|
/>
|
||||||
});
|
</div>
|
||||||
}}
|
|
||||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canUpdateService && (
|
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||||
<div className="flex flex-row items-center gap-2 justify-between rounded-md px-4 py-2 border col-span-2 md:col-span-1">
|
<span className="text-sm font-medium">Clean Cache</span>
|
||||||
<span className="text-sm font-medium">Clean Cache</span>
|
<Switch
|
||||||
<Switch
|
aria-label="Toggle clean cache"
|
||||||
aria-label="Toggle clean cache"
|
checked={data?.cleanCache || false}
|
||||||
checked={data?.cleanCache || false}
|
onCheckedChange={async (enabled) => {
|
||||||
onCheckedChange={async (enabled) => {
|
await update({
|
||||||
await update({
|
applicationId,
|
||||||
applicationId,
|
cleanCache: enabled,
|
||||||
cleanCache: enabled,
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success("Clean Cache Updated");
|
||||||
|
await refetch();
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.catch(() => {
|
||||||
toast.success("Clean Cache Updated");
|
toast.error("Error updating Clean Cache");
|
||||||
await refetch();
|
});
|
||||||
})
|
}}
|
||||||
.catch(() => {
|
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||||
toast.error("Error updating Clean Cache");
|
/>
|
||||||
});
|
</div>
|
||||||
}}
|
|
||||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<ShowProviderForm applicationId={applicationId} />
|
<ShowProviderForm applicationId={applicationId} />
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const AddPreviewDomain = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const host = form.watch("host");
|
const host = form.watch("host");
|
||||||
const isTraefikMeDomain = host?.includes("sslip.io") || false;
|
const isTraefikMeDomain = host?.includes("traefik.me") || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -162,7 +162,7 @@ export const AddPreviewDomain = ({
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
{isTraefikMeDomain && (
|
{isTraefikMeDomain && (
|
||||||
<AlertBlock type="info">
|
<AlertBlock type="info">
|
||||||
<strong>Note:</strong> sslip.io is a public HTTP
|
<strong>Note:</strong> traefik.me is a public HTTP
|
||||||
service and does not support SSL/HTTPS. HTTPS and
|
service and does not support SSL/HTTPS. HTTPS and
|
||||||
certificate options will not have any effect.
|
certificate options will not have any effect.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
@@ -202,7 +202,7 @@ export const AddPreviewDomain = ({
|
|||||||
sideOffset={5}
|
sideOffset={5}
|
||||||
className="max-w-[10rem]"
|
className="max-w-[10rem]"
|
||||||
>
|
>
|
||||||
<p>Generate sslip.io domain</p>
|
<p>Generate traefik.me domain</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
const form = useForm<Schema>({
|
const form = useForm<Schema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
env: "",
|
env: "",
|
||||||
wildcardDomain: "*.sslip.io",
|
wildcardDomain: "*.traefik.me",
|
||||||
port: 3000,
|
port: 3000,
|
||||||
previewLimit: 3,
|
previewLimit: 3,
|
||||||
previewLabels: [],
|
previewLabels: [],
|
||||||
@@ -102,7 +102,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
|
|
||||||
const previewHttps = form.watch("previewHttps");
|
const previewHttps = form.watch("previewHttps");
|
||||||
const wildcardDomain = form.watch("wildcardDomain");
|
const wildcardDomain = form.watch("wildcardDomain");
|
||||||
const isTraefikMeDomain = wildcardDomain?.includes("sslip.io") || false;
|
const isTraefikMeDomain = wildcardDomain?.includes("traefik.me") || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsEnabled(data?.isPreviewDeploymentsActive || false);
|
setIsEnabled(data?.isPreviewDeploymentsActive || false);
|
||||||
@@ -114,7 +114,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
env: data.previewEnv || "",
|
env: data.previewEnv || "",
|
||||||
buildArgs: data.previewBuildArgs || "",
|
buildArgs: data.previewBuildArgs || "",
|
||||||
buildSecrets: data.previewBuildSecrets || "",
|
buildSecrets: data.previewBuildSecrets || "",
|
||||||
wildcardDomain: data.previewWildcard || "*.sslip.io",
|
wildcardDomain: data.previewWildcard || "*.traefik.me",
|
||||||
port: data.previewPort || 3000,
|
port: data.previewPort || 3000,
|
||||||
previewLabels: data.previewLabels || [],
|
previewLabels: data.previewLabels || [],
|
||||||
previewLimit: data.previewLimit || 3,
|
previewLimit: data.previewLimit || 3,
|
||||||
@@ -173,7 +173,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{isTraefikMeDomain && (
|
{isTraefikMeDomain && (
|
||||||
<AlertBlock type="info">
|
<AlertBlock type="info">
|
||||||
<strong>Note:</strong> sslip.io is a public HTTP service and
|
<strong>Note:</strong> traefik.me is a public HTTP service and
|
||||||
does not support SSL/HTTPS. HTTPS and certificate options will
|
does not support SSL/HTTPS. HTTPS and certificate options will
|
||||||
not have any effect.
|
not have any effect.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
@@ -192,7 +192,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Wildcard Domain</FormLabel>
|
<FormLabel>Wildcard Domain</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="*.sslip.io" {...field} />
|
<Input placeholder="*.traefik.me" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ export const commonCronExpressions = [
|
|||||||
const formSchema = z
|
const formSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
description: z.string().optional(),
|
|
||||||
cronExpression: z.string().min(1, "Cron expression is required"),
|
cronExpression: z.string().min(1, "Cron expression is required"),
|
||||||
shellType: z.enum(["bash", "sh"]).default("bash"),
|
shellType: z.enum(["bash", "sh"]).default("bash"),
|
||||||
command: z.string(),
|
command: z.string(),
|
||||||
@@ -225,7 +224,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
resolver: standardSchemaResolver(formSchema),
|
resolver: standardSchemaResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
|
||||||
cronExpression: "",
|
cronExpression: "",
|
||||||
shellType: "bash",
|
shellType: "bash",
|
||||||
command: "",
|
command: "",
|
||||||
@@ -265,7 +263,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
if (scheduleId && schedule) {
|
if (scheduleId && schedule) {
|
||||||
form.reset({
|
form.reset({
|
||||||
name: schedule.name,
|
name: schedule.name,
|
||||||
description: schedule.description || "",
|
|
||||||
cronExpression: schedule.cronExpression,
|
cronExpression: schedule.cronExpression,
|
||||||
shellType: schedule.shellType,
|
shellType: schedule.shellType,
|
||||||
command: schedule.command,
|
command: schedule.command,
|
||||||
@@ -482,26 +479,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="description"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Description</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="Backs up the database every day at midnight"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Optional description of what this schedule does
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ScheduleFormField
|
<ScheduleFormField
|
||||||
name="cronExpression"
|
name="cronExpression"
|
||||||
formControl={form.control}
|
formControl={form.control}
|
||||||
|
|||||||
@@ -125,11 +125,6 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
{schedule.enabled ? "Enabled" : "Disabled"}
|
{schedule.enabled ? "Enabled" : "Disabled"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{schedule.description && (
|
|
||||||
<p className="text-xs text-muted-foreground/70 [overflow-wrap:anywhere] line-clamp-2">
|
|
||||||
{schedule.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -1,290 +0,0 @@
|
|||||||
import { Loader2, MoreHorizontal, RefreshCw } from "lucide-react";
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { ShowContainerConfig } from "@/components/dashboard/docker/config/show-container-config";
|
|
||||||
import { ShowContainerMounts } from "@/components/dashboard/docker/mounts/show-container-mounts";
|
|
||||||
import { ShowContainerNetworks } from "@/components/dashboard/docker/networks/show-container-networks";
|
|
||||||
import { DockerTerminalModal } from "@/components/dashboard/docker/terminal/docker-terminal-modal";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
const DockerLogsId = dynamic(
|
|
||||||
() =>
|
|
||||||
import("@/components/dashboard/docker/logs/docker-logs-id").then(
|
|
||||||
(e) => e.DockerLogsId,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
ssr: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
appName: string;
|
|
||||||
serverId?: string;
|
|
||||||
appType: "stack" | "docker-compose";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ShowComposeContainers = ({
|
|
||||||
appName,
|
|
||||||
appType,
|
|
||||||
serverId,
|
|
||||||
}: Props) => {
|
|
||||||
const { data, isPending, refetch } =
|
|
||||||
api.docker.getContainersByAppNameMatch.useQuery(
|
|
||||||
{
|
|
||||||
appName,
|
|
||||||
appType,
|
|
||||||
serverId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!appName,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="bg-background">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-xl">Containers</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Inspect each container in this compose and run basic lifecycle
|
|
||||||
actions.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => refetch()}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
<RefreshCw className={`h-4 w-4 ${isPending ? "animate-spin" : ""}`} />
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isPending ? (
|
|
||||||
<div className="flex items-center justify-center h-[20vh]">
|
|
||||||
<Loader2 className="animate-spin h-6 w-6 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : !data || data.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center h-[20vh]">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
No containers found. Deploy the compose to see containers here.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-md border">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Name</TableHead>
|
|
||||||
<TableHead>State</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Container ID</TableHead>
|
|
||||||
<TableHead className="text-right" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{data.map((container) => (
|
|
||||||
<ContainerRow
|
|
||||||
key={container.containerId}
|
|
||||||
container={container}
|
|
||||||
serverId={serverId}
|
|
||||||
onActionComplete={() => refetch()}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ContainerRowProps {
|
|
||||||
container: {
|
|
||||||
containerId: string;
|
|
||||||
name: string;
|
|
||||||
state: string;
|
|
||||||
status: string;
|
|
||||||
};
|
|
||||||
serverId?: string;
|
|
||||||
onActionComplete: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ContainerRow = ({
|
|
||||||
container,
|
|
||||||
serverId,
|
|
||||||
onActionComplete,
|
|
||||||
}: ContainerRowProps) => {
|
|
||||||
const [logsOpen, setLogsOpen] = useState(false);
|
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const restartMutation = api.docker.restartContainer.useMutation();
|
|
||||||
const startMutation = api.docker.startContainer.useMutation();
|
|
||||||
const stopMutation = api.docker.stopContainer.useMutation();
|
|
||||||
const killMutation = api.docker.killContainer.useMutation();
|
|
||||||
|
|
||||||
const handleAction = async (
|
|
||||||
action: string,
|
|
||||||
mutationFn: typeof restartMutation,
|
|
||||||
) => {
|
|
||||||
setActionLoading(action);
|
|
||||||
try {
|
|
||||||
await mutationFn.mutateAsync({
|
|
||||||
containerId: container.containerId,
|
|
||||||
serverId,
|
|
||||||
});
|
|
||||||
toast.success(`Container ${action} successfully`);
|
|
||||||
onActionComplete();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
`Failed to ${action} container: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setActionLoading(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell className="font-medium">{container.name}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
container.state === "running"
|
|
||||||
? "default"
|
|
||||||
: container.state === "exited"
|
|
||||||
? "secondary"
|
|
||||||
: "destructive"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{container.state}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{container.status}</TableCell>
|
|
||||||
<TableCell className="font-mono text-sm text-muted-foreground">
|
|
||||||
{container.containerId}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<Dialog open={logsOpen} onOpenChange={setLogsOpen}>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
|
||||||
{actionLoading ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="cursor-pointer"
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
View Logs
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DialogTrigger>
|
|
||||||
<ShowContainerConfig
|
|
||||||
containerId={container.containerId}
|
|
||||||
serverId={serverId || ""}
|
|
||||||
/>
|
|
||||||
<ShowContainerMounts
|
|
||||||
containerId={container.containerId}
|
|
||||||
serverId={serverId || ""}
|
|
||||||
/>
|
|
||||||
<ShowContainerNetworks
|
|
||||||
containerId={container.containerId}
|
|
||||||
serverId={serverId || ""}
|
|
||||||
/>
|
|
||||||
<DockerTerminalModal
|
|
||||||
containerId={container.containerId}
|
|
||||||
serverId={serverId || ""}
|
|
||||||
>
|
|
||||||
Terminal
|
|
||||||
</DockerTerminalModal>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="cursor-pointer"
|
|
||||||
disabled={actionLoading !== null}
|
|
||||||
onClick={() => handleAction("restart", restartMutation)}
|
|
||||||
>
|
|
||||||
Restart
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="cursor-pointer"
|
|
||||||
disabled={actionLoading !== null}
|
|
||||||
onClick={() => handleAction("start", startMutation)}
|
|
||||||
>
|
|
||||||
Start
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="cursor-pointer"
|
|
||||||
disabled={actionLoading !== null}
|
|
||||||
onClick={() => handleAction("stop", stopMutation)}
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="cursor-pointer text-red-500 focus:text-red-600"
|
|
||||||
disabled={actionLoading !== null}
|
|
||||||
onClick={() => handleAction("kill", killMutation)}
|
|
||||||
>
|
|
||||||
Kill
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<DialogContent className="sm:max-w-7xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>View Logs</DialogTitle>
|
|
||||||
<DialogDescription>Logs for {container.name}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
|
||||||
<DockerLogsId
|
|
||||||
containerId={container.containerId}
|
|
||||||
serverId={serverId}
|
|
||||||
runType="native"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -46,8 +46,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DeleteService = ({ id, type }: Props) => {
|
export const DeleteService = ({ id, type }: Props) => {
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
|
||||||
const canDelete = permissions?.service.delete ?? false;
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const queryMap = {
|
const queryMap = {
|
||||||
@@ -57,7 +55,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 +70,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 +96,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,
|
||||||
@@ -128,8 +123,6 @@ export const DeleteService = ({ id, type }: Props) => {
|
|||||||
data?.applicationStatus === "running") ||
|
data?.applicationStatus === "running") ||
|
||||||
(data && "composeStatus" in data && data?.composeStatus === "running");
|
(data && "composeStatus" in data && data?.composeStatus === "running");
|
||||||
|
|
||||||
if (!canDelete) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
export const ComposeActions = ({ composeId }: Props) => {
|
export const ComposeActions = ({ composeId }: Props) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
|
||||||
const canDeploy = permissions?.deployment.create ?? false;
|
|
||||||
const canUpdateService = permissions?.service.create ?? false;
|
|
||||||
const { data, refetch } = api.compose.one.useQuery(
|
const { data, refetch } = api.compose.one.useQuery(
|
||||||
{
|
{
|
||||||
composeId,
|
composeId,
|
||||||
@@ -38,169 +35,162 @@ export const ComposeActions = ({ composeId }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-row gap-4 w-full flex-wrap ">
|
<div className="flex flex-row gap-4 w-full flex-wrap ">
|
||||||
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
||||||
{canDeploy && (
|
<DialogAction
|
||||||
|
title="Deploy Compose"
|
||||||
|
description="Are you sure you want to deploy this compose?"
|
||||||
|
type="default"
|
||||||
|
onClick={async () => {
|
||||||
|
await deploy({
|
||||||
|
composeId: composeId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Compose deployed successfully");
|
||||||
|
refetch();
|
||||||
|
router.push(
|
||||||
|
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error deploying compose");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
isLoading={data?.composeStatus === "running"}
|
||||||
|
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Rocket className="size-4 mr-1" />
|
||||||
|
Deploy
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
|
<p>Downloads the source code and performs a complete build</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
</Tooltip>
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
<DialogAction
|
||||||
|
title="Reload Compose"
|
||||||
|
description="Are you sure you want to reload this compose?"
|
||||||
|
type="default"
|
||||||
|
onClick={async () => {
|
||||||
|
await redeploy({
|
||||||
|
composeId: composeId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Compose reloaded successfully");
|
||||||
|
refetch();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error reloading compose");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
isLoading={data?.composeStatus === "running"}
|
||||||
|
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<RefreshCcw className="size-4 mr-1" />
|
||||||
|
Reload
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
|
<p>Reload the compose without rebuilding it</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
</Tooltip>
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
{data?.composeType === "docker-compose" &&
|
||||||
|
data?.composeStatus === "idle" ? (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Deploy Compose"
|
title="Start Compose"
|
||||||
description="Are you sure you want to deploy this compose?"
|
description="Are you sure you want to start this compose?"
|
||||||
type="default"
|
type="default"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await deploy({
|
await start({
|
||||||
composeId: composeId,
|
composeId: composeId,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("Compose deployed successfully");
|
toast.success("Compose started successfully");
|
||||||
refetch();
|
refetch();
|
||||||
router.push(
|
|
||||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error deploying compose");
|
toast.error("Error starting compose");
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="secondary"
|
||||||
isLoading={data?.composeStatus === "running"}
|
isLoading={isStarting}
|
||||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Rocket className="size-4 mr-1" />
|
<CheckCircle2 className="size-4 mr-1" />
|
||||||
Deploy
|
Start
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPrimitive.Portal>
|
<TooltipPrimitive.Portal>
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
<p>
|
<p>
|
||||||
Downloads the source code and performs a complete build
|
Start the compose (requires a previous successful build)
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
)}
|
) : (
|
||||||
{canDeploy && (
|
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Reload Compose"
|
title="Stop Compose"
|
||||||
description="Are you sure you want to reload this compose?"
|
description="Are you sure you want to stop this compose?"
|
||||||
type="default"
|
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await redeploy({
|
await stop({
|
||||||
composeId: composeId,
|
composeId: composeId,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("Compose reloaded successfully");
|
toast.success("Compose stopped successfully");
|
||||||
refetch();
|
refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error reloading compose");
|
toast.error("Error stopping compose");
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="destructive"
|
||||||
isLoading={data?.composeStatus === "running"}
|
isLoading={isStopping}
|
||||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<RefreshCcw className="size-4 mr-1" />
|
<Ban className="size-4 mr-1" />
|
||||||
Reload
|
Stop
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPrimitive.Portal>
|
<TooltipPrimitive.Portal>
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
<p>Reload the compose without rebuilding it</p>
|
<p>Stop the currently running compose</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
)}
|
)}
|
||||||
{canDeploy &&
|
|
||||||
(data?.composeType === "docker-compose" &&
|
|
||||||
data?.composeStatus === "idle" ? (
|
|
||||||
<DialogAction
|
|
||||||
title="Start Compose"
|
|
||||||
description="Are you sure you want to start this compose?"
|
|
||||||
type="default"
|
|
||||||
onClick={async () => {
|
|
||||||
await start({
|
|
||||||
composeId: composeId,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success("Compose started successfully");
|
|
||||||
refetch();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error starting compose");
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
isLoading={isStarting}
|
|
||||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<CheckCircle2 className="size-4 mr-1" />
|
|
||||||
Start
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipPrimitive.Portal>
|
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
|
||||||
<p>
|
|
||||||
Start the compose (requires a previous successful build)
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</TooltipPrimitive.Portal>
|
|
||||||
</Tooltip>
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
) : (
|
|
||||||
<DialogAction
|
|
||||||
title="Stop Compose"
|
|
||||||
description="Are you sure you want to stop this compose?"
|
|
||||||
onClick={async () => {
|
|
||||||
await stop({
|
|
||||||
composeId: composeId,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success("Compose stopped successfully");
|
|
||||||
refetch();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error stopping compose");
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
isLoading={isStopping}
|
|
||||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Ban className="size-4 mr-1" />
|
|
||||||
Stop
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipPrimitive.Portal>
|
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
|
||||||
<p>Stop the currently running compose</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</TooltipPrimitive.Portal>
|
|
||||||
</Tooltip>
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
))}
|
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<DockerTerminalModal
|
<DockerTerminalModal
|
||||||
appName={data?.appName || ""}
|
appName={data?.appName || ""}
|
||||||
@@ -215,29 +205,27 @@ export const ComposeActions = ({ composeId }: Props) => {
|
|||||||
Open Terminal
|
Open Terminal
|
||||||
</Button>
|
</Button>
|
||||||
</DockerTerminalModal>
|
</DockerTerminalModal>
|
||||||
{canUpdateService && (
|
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
<span className="text-sm font-medium">Autodeploy</span>
|
||||||
<span className="text-sm font-medium">Autodeploy</span>
|
<Switch
|
||||||
<Switch
|
aria-label="Toggle autodeploy"
|
||||||
aria-label="Toggle autodeploy"
|
checked={data?.autoDeploy || false}
|
||||||
checked={data?.autoDeploy || false}
|
onCheckedChange={async (enabled) => {
|
||||||
onCheckedChange={async (enabled) => {
|
await update({
|
||||||
await update({
|
composeId,
|
||||||
composeId,
|
autoDeploy: enabled,
|
||||||
autoDeploy: enabled,
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success("Auto Deploy Updated");
|
||||||
|
await refetch();
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.catch(() => {
|
||||||
toast.success("Auto Deploy Updated");
|
toast.error("Error updating Auto Deploy");
|
||||||
await refetch();
|
});
|
||||||
})
|
}}
|
||||||
.catch(() => {
|
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||||
toast.error("Error updating Auto Deploy");
|
/>
|
||||||
});
|
</div>
|
||||||
}}
|
|
||||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ const AddComposeFile = z.object({
|
|||||||
type AddComposeFile = z.infer<typeof AddComposeFile>;
|
type AddComposeFile = z.infer<typeof AddComposeFile>;
|
||||||
|
|
||||||
export const ComposeFileEditor = ({ composeId }: Props) => {
|
export const ComposeFileEditor = ({ composeId }: Props) => {
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
|
||||||
const canUpdate = permissions?.service.create ?? false;
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { data, refetch } = api.compose.one.useQuery(
|
const { data, refetch } = api.compose.one.useQuery(
|
||||||
{
|
{
|
||||||
@@ -49,12 +47,12 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
|||||||
const composeFile = form.watch("composeFile");
|
const composeFile = form.watch("composeFile");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data && !composeFile) {
|
||||||
form.reset({
|
form.reset({
|
||||||
composeFile: data.composeFile || "",
|
composeFile: data.composeFile || "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, data]);
|
}, [form, form.reset, data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.composeFile !== undefined) {
|
if (data?.composeFile !== undefined) {
|
||||||
@@ -95,7 +93,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)();
|
||||||
}
|
}
|
||||||
@@ -166,16 +164,14 @@ services:
|
|||||||
</Form>
|
</Form>
|
||||||
<div className="flex justify-between flex-col lg:flex-row gap-2">
|
<div className="flex justify-between flex-col lg:flex-row gap-2">
|
||||||
<div className="w-full flex flex-col lg:flex-row gap-4 items-end" />
|
<div className="w-full flex flex-col lg:flex-row gap-4 items-end" />
|
||||||
{canUpdate && (
|
<Button
|
||||||
<Button
|
type="submit"
|
||||||
type="submit"
|
form="hook-form-save-compose-file"
|
||||||
form="hook-form-save-compose-file"
|
isLoading={isPending}
|
||||||
isLoading={isPending}
|
className="lg:w-fit w-full"
|
||||||
className="lg:w-fit w-full"
|
>
|
||||||
>
|
Save
|
||||||
Save
|
</Button>
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -58,10 +57,7 @@ const BitbucketProviderSchema = z.object({
|
|||||||
slug: z.string().optional(),
|
slug: z.string().optional(),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch is required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch is required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
@@ -422,7 +418,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger>
|
||||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||||
?
|
?
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
|
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -42,10 +41,7 @@ const GitProviderSchema = z.object({
|
|||||||
repositoryURL: z.string().min(1, {
|
repositoryURL: z.string().min(1, {
|
||||||
message: "Repository URL is required",
|
message: "Repository URL is required",
|
||||||
}),
|
}),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
sshKey: z.string().optional(),
|
sshKey: z.string().optional(),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
@@ -59,7 +55,7 @@ interface Props {
|
|||||||
|
|
||||||
export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||||
const { data, refetch } = api.compose.one.useQuery({ composeId });
|
const { data, refetch } = api.compose.one.useQuery({ composeId });
|
||||||
const { data: sshKeys } = api.sshKey.allForApps.useQuery();
|
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { mutateAsync, isPending } = api.compose.update.useMutation();
|
const { mutateAsync, isPending } = api.compose.update.useMutation();
|
||||||
@@ -234,8 +230,10 @@ export const SaveGitProviderCompose = ({ 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 className="max-w-[300px]">
|
<TooltipContent className="max-w-[300px]">
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } 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";
|
||||||
@@ -58,10 +57,7 @@ const GiteaProviderSchema = z.object({
|
|||||||
owner: z.string().min(1, "Owner is required"),
|
owner: z.string().min(1, "Owner is required"),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch is required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch is required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
giteaId: z.string().min(1, "Gitea Provider is required"),
|
giteaId: z.string().min(1, "Gitea Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
@@ -413,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>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -56,10 +55,7 @@ const GithubProviderSchema = z.object({
|
|||||||
owner: z.string().min(1, "Owner is required"),
|
owner: z.string().min(1, "Owner is required"),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch is required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch is required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
githubId: z.string().min(1, "Github Provider is required"),
|
githubId: z.string().min(1, "Github Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
triggerType: z.enum(["push", "tag"]).default("push"),
|
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||||
@@ -449,7 +445,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger>
|
||||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||||
?
|
?
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -59,10 +58,7 @@ const GitlabProviderSchema = z.object({
|
|||||||
gitlabPathNamespace: z.string().min(1),
|
gitlabPathNamespace: z.string().min(1),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch is required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch is required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
@@ -440,7 +436,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger>
|
||||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||||
?
|
?
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -288,6 +283,7 @@ export const RestoreBackup = ({
|
|||||||
toast.error("Please select a database type");
|
toast.error("Please select a database type");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
console.log({ data });
|
||||||
setIsDeploying(true);
|
setIsDeploying(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -527,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,613 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
type ColumnFiltersState,
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
getFilteredRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
type PaginationState,
|
|
||||||
type SortingState,
|
|
||||||
useReactTable,
|
|
||||||
} from "@tanstack/react-table";
|
|
||||||
import type { inferRouterOutputs } from "@trpc/server";
|
|
||||||
import {
|
|
||||||
ArrowUpDown,
|
|
||||||
Boxes,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
ExternalLink,
|
|
||||||
Loader2,
|
|
||||||
Rocket,
|
|
||||||
Server,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import type { AppRouter } from "@/server/api/root";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
type DeploymentRow =
|
|
||||||
inferRouterOutputs<AppRouter>["deployment"]["allCentralized"][number];
|
|
||||||
|
|
||||||
const statusVariants: Record<
|
|
||||||
string,
|
|
||||||
| "default"
|
|
||||||
| "secondary"
|
|
||||||
| "destructive"
|
|
||||||
| "outline"
|
|
||||||
| "yellow"
|
|
||||||
| "green"
|
|
||||||
| "red"
|
|
||||||
> = {
|
|
||||||
running: "yellow",
|
|
||||||
done: "green",
|
|
||||||
error: "red",
|
|
||||||
cancelled: "outline",
|
|
||||||
};
|
|
||||||
|
|
||||||
function getServiceInfo(d: DeploymentRow) {
|
|
||||||
const app = d.application;
|
|
||||||
const comp = d.compose;
|
|
||||||
if (app?.environment?.project && app.environment) {
|
|
||||||
return {
|
|
||||||
type: "Application" as const,
|
|
||||||
name: app.name,
|
|
||||||
projectId: app.environment.project.projectId,
|
|
||||||
environmentId: app.environment.environmentId,
|
|
||||||
projectName: app.environment.project.name,
|
|
||||||
environmentName: app.environment.name,
|
|
||||||
serviceId: app.applicationId,
|
|
||||||
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (comp?.environment?.project && comp.environment) {
|
|
||||||
return {
|
|
||||||
type: "Compose" as const,
|
|
||||||
name: comp.name,
|
|
||||||
projectId: comp.environment.project.projectId,
|
|
||||||
environmentId: comp.environment.environmentId,
|
|
||||||
projectName: comp.environment.project.name,
|
|
||||||
environmentName: comp.environment.name,
|
|
||||||
serviceId: comp.composeId,
|
|
||||||
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ShowDeploymentsTable() {
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([
|
|
||||||
{ id: "createdAt", desc: true },
|
|
||||||
]);
|
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
|
||||||
const [globalFilter, setGlobalFilter] = useState("");
|
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
|
||||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: 50,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: deploymentsList, isLoading } =
|
|
||||||
api.deployment.allCentralized.useQuery(undefined, {
|
|
||||||
refetchInterval: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
|
||||||
if (!deploymentsList) return [];
|
|
||||||
let list = deploymentsList;
|
|
||||||
if (statusFilter !== "all") {
|
|
||||||
list = list.filter((d) => d.status === statusFilter);
|
|
||||||
}
|
|
||||||
if (typeFilter === "application") {
|
|
||||||
list = list.filter((d) => d.applicationId != null);
|
|
||||||
} else if (typeFilter === "compose") {
|
|
||||||
list = list.filter((d) => d.composeId != null);
|
|
||||||
}
|
|
||||||
if (globalFilter.trim()) {
|
|
||||||
const q = globalFilter.toLowerCase();
|
|
||||||
list = list.filter((d) => {
|
|
||||||
const info = getServiceInfo(d);
|
|
||||||
const serverName =
|
|
||||||
d.server?.name ??
|
|
||||||
d.application?.server?.name ??
|
|
||||||
d.compose?.server?.name ??
|
|
||||||
"";
|
|
||||||
const buildServerName =
|
|
||||||
d.buildServer?.name ?? d.application?.buildServer?.name ?? "";
|
|
||||||
if (!info) return false;
|
|
||||||
return (
|
|
||||||
info.name.toLowerCase().includes(q) ||
|
|
||||||
info.projectName.toLowerCase().includes(q) ||
|
|
||||||
info.environmentName.toLowerCase().includes(q) ||
|
|
||||||
(d.title?.toLowerCase().includes(q) ?? false) ||
|
|
||||||
serverName.toLowerCase().includes(q) ||
|
|
||||||
buildServerName.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return list;
|
|
||||||
}, [deploymentsList, statusFilter, typeFilter, globalFilter]);
|
|
||||||
|
|
||||||
const columns = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
id: "serviceName",
|
|
||||||
accessorFn: (row: DeploymentRow) => getServiceInfo(row)?.name ?? "",
|
|
||||||
header: ({
|
|
||||||
column,
|
|
||||||
}: {
|
|
||||||
column: {
|
|
||||||
getIsSorted: () => false | "asc" | "desc";
|
|
||||||
toggleSorting: (asc: boolean) => void;
|
|
||||||
};
|
|
||||||
}) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="-ml-3 h-8"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Service
|
|
||||||
<ArrowUpDown className="ml-2 size-4" />
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
cell: ({ row }: { row: { original: DeploymentRow } }) => {
|
|
||||||
const info = getServiceInfo(row.original);
|
|
||||||
if (!info) return <span className="text-muted-foreground">—</span>;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{info.type === "Application" ? (
|
|
||||||
<Rocket className="size-4 text-muted-foreground shrink-0" />
|
|
||||||
) : (
|
|
||||||
<Boxes className="size-4 text-muted-foreground shrink-0" />
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col min-w-0">
|
|
||||||
<span className="font-medium truncate">{info.name}</span>
|
|
||||||
<Badge variant="outline" className="w-fit text-[10px]">
|
|
||||||
{info.type}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "projectName",
|
|
||||||
accessorFn: (row: DeploymentRow) =>
|
|
||||||
getServiceInfo(row)?.projectName ?? "",
|
|
||||||
header: ({
|
|
||||||
column,
|
|
||||||
}: {
|
|
||||||
column: {
|
|
||||||
getIsSorted: () => false | "asc" | "desc";
|
|
||||||
toggleSorting: (asc: boolean) => void;
|
|
||||||
};
|
|
||||||
}) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="-ml-3 h-8"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Project
|
|
||||||
<ArrowUpDown className="ml-2 size-4" />
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
cell: ({ row }: { row: { original: DeploymentRow } }) => {
|
|
||||||
const info = getServiceInfo(row.original);
|
|
||||||
return (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{info?.projectName ?? "—"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "environmentName",
|
|
||||||
accessorFn: (row: DeploymentRow) =>
|
|
||||||
getServiceInfo(row)?.environmentName ?? "",
|
|
||||||
header: ({
|
|
||||||
column,
|
|
||||||
}: {
|
|
||||||
column: {
|
|
||||||
getIsSorted: () => false | "asc" | "desc";
|
|
||||||
toggleSorting: (asc: boolean) => void;
|
|
||||||
};
|
|
||||||
}) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="-ml-3 h-8"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Environment
|
|
||||||
<ArrowUpDown className="ml-2 size-4" />
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
cell: ({ row }: { row: { original: DeploymentRow } }) => {
|
|
||||||
const info = getServiceInfo(row.original);
|
|
||||||
return (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{info?.environmentName ?? "—"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "serverName",
|
|
||||||
accessorFn: (row: DeploymentRow) =>
|
|
||||||
row.server?.name ??
|
|
||||||
row.application?.server?.name ??
|
|
||||||
row.compose?.server?.name ??
|
|
||||||
"",
|
|
||||||
header: ({
|
|
||||||
column,
|
|
||||||
}: {
|
|
||||||
column: {
|
|
||||||
getIsSorted: () => false | "asc" | "desc";
|
|
||||||
toggleSorting: (asc: boolean) => void;
|
|
||||||
};
|
|
||||||
}) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="-ml-3 h-8"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Server
|
|
||||||
<ArrowUpDown className="ml-2 size-4" />
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
cell: ({ row }: { row: { original: DeploymentRow } }) => {
|
|
||||||
const d = row.original;
|
|
||||||
const serverName =
|
|
||||||
d.server?.name ??
|
|
||||||
d.application?.server?.name ??
|
|
||||||
d.compose?.server?.name ??
|
|
||||||
null;
|
|
||||||
const serverType =
|
|
||||||
d.server?.serverType ??
|
|
||||||
d.application?.server?.serverType ??
|
|
||||||
d.compose?.server?.serverType ??
|
|
||||||
null;
|
|
||||||
const buildServerName =
|
|
||||||
d.buildServer?.name ?? d.application?.buildServer?.name ?? null;
|
|
||||||
const buildServerType =
|
|
||||||
d.buildServer?.serverType ??
|
|
||||||
d.application?.buildServer?.serverType ??
|
|
||||||
null;
|
|
||||||
const showBuild =
|
|
||||||
buildServerName != null && buildServerName !== serverName;
|
|
||||||
if (!serverName && !showBuild) {
|
|
||||||
return <span className="text-muted-foreground">—</span>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-0.5 text-sm">
|
|
||||||
{serverName && (
|
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
|
||||||
<Server className="size-3.5 text-muted-foreground shrink-0" />
|
|
||||||
<span className="truncate">{serverName}</span>
|
|
||||||
{serverType && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-[10px] font-normal"
|
|
||||||
>
|
|
||||||
{serverType}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showBuild && buildServerName && (
|
|
||||||
<div className="flex items-center gap-1.5 text-muted-foreground flex-wrap">
|
|
||||||
<span className="text-[10px]">Build:</span>
|
|
||||||
<span className="truncate text-xs">{buildServerName}</span>
|
|
||||||
{buildServerType && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-[10px] font-normal"
|
|
||||||
>
|
|
||||||
{buildServerType}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "title",
|
|
||||||
header: ({
|
|
||||||
column,
|
|
||||||
}: {
|
|
||||||
column: {
|
|
||||||
getIsSorted: () => false | "asc" | "desc";
|
|
||||||
toggleSorting: (asc: boolean) => void;
|
|
||||||
};
|
|
||||||
}) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="-ml-3 h-8"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Title
|
|
||||||
<ArrowUpDown className="ml-2 size-4" />
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
cell: ({ row }: { row: { original: DeploymentRow } }) => (
|
|
||||||
<span className="text-sm truncate max-w-[200px] block">
|
|
||||||
{row.original.title || "—"}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "status",
|
|
||||||
header: ({
|
|
||||||
column,
|
|
||||||
}: {
|
|
||||||
column: {
|
|
||||||
getIsSorted: () => false | "asc" | "desc";
|
|
||||||
toggleSorting: (asc: boolean) => void;
|
|
||||||
};
|
|
||||||
}) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="-ml-3 h-8"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Status
|
|
||||||
<ArrowUpDown className="ml-2 size-4" />
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
cell: ({ row }: { row: { original: DeploymentRow } }) => {
|
|
||||||
const status = row.original.status ?? "running";
|
|
||||||
return (
|
|
||||||
<Badge variant={statusVariants[status] ?? "secondary"}>
|
|
||||||
{status}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "createdAt",
|
|
||||||
header: ({
|
|
||||||
column,
|
|
||||||
}: {
|
|
||||||
column: {
|
|
||||||
getIsSorted: () => false | "asc" | "desc";
|
|
||||||
toggleSorting: (asc: boolean) => void;
|
|
||||||
};
|
|
||||||
}) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="-ml-3 h-8"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Created
|
|
||||||
<ArrowUpDown className="ml-2 size-4" />
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
cell: ({ row }: { row: { original: DeploymentRow } }) => (
|
|
||||||
<span className="text-muted-foreground text-sm whitespace-nowrap">
|
|
||||||
{row.original.createdAt
|
|
||||||
? new Date(row.original.createdAt).toLocaleString()
|
|
||||||
: "—"}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "",
|
|
||||||
id: "actions",
|
|
||||||
enableSorting: false,
|
|
||||||
cell: ({ row }: { row: { original: DeploymentRow } }) => {
|
|
||||||
const info = getServiceInfo(row.original);
|
|
||||||
if (!info) return null;
|
|
||||||
return (
|
|
||||||
<Button variant="ghost" size="sm" asChild>
|
|
||||||
<Link href={info.href} className="gap-1">
|
|
||||||
<ExternalLink className="size-4" />
|
|
||||||
Open
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: filteredData,
|
|
||||||
columns,
|
|
||||||
state: {
|
|
||||||
sorting,
|
|
||||||
columnFilters,
|
|
||||||
globalFilter,
|
|
||||||
pagination,
|
|
||||||
},
|
|
||||||
onSortingChange: setSorting,
|
|
||||||
onColumnFiltersChange: setColumnFilters,
|
|
||||||
onGlobalFilterChange: setGlobalFilter,
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Input
|
|
||||||
placeholder="Search by name, project, environment, server..."
|
|
||||||
value={globalFilter}
|
|
||||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
|
||||||
className="max-w-xs"
|
|
||||||
/>
|
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
||||||
<SelectTrigger className="w-[140px]">
|
|
||||||
<SelectValue placeholder="Status" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All statuses</SelectItem>
|
|
||||||
<SelectItem value="running">Running</SelectItem>
|
|
||||||
<SelectItem value="done">Done</SelectItem>
|
|
||||||
<SelectItem value="error">Error</SelectItem>
|
|
||||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
|
||||||
<SelectTrigger className="w-[140px]">
|
|
||||||
<SelectValue placeholder="Type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All types</SelectItem>
|
|
||||||
<SelectItem value="application">Application</SelectItem>
|
|
||||||
<SelectItem value="compose">Compose</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="px-0">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
<span>Loading deployments...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="rounded-md border overflow-x-auto">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<TableRow key={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header) => (
|
|
||||||
<TableHead key={header.id}>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext(),
|
|
||||||
)}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{table.getRowModel().rows?.length ? (
|
|
||||||
table.getRowModel().rows.map((row) => (
|
|
||||||
<TableRow key={row.id}>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<TableCell key={cell.id}>
|
|
||||||
{flexRender(
|
|
||||||
cell.column.columnDef.cell,
|
|
||||||
cell.getContext(),
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={columns.length}
|
|
||||||
className=" text-center"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
|
|
||||||
<Rocket className="size-8" />
|
|
||||||
<p className="font-medium">No deployments found</p>
|
|
||||||
<p className="text-sm">
|
|
||||||
Deployments from applications and compose will
|
|
||||||
appear here.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
|
||||||
Rows per page
|
|
||||||
</span>
|
|
||||||
<Select
|
|
||||||
value={String(pagination.pageSize)}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setPagination((p) => ({
|
|
||||||
...p,
|
|
||||||
pageSize: Number(value),
|
|
||||||
pageIndex: 0,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-[70px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent side="top">
|
|
||||||
{[10, 25, 50, 100].map((size) => (
|
|
||||||
<SelectItem key={size} value={String(size)}>
|
|
||||||
{size}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
|
||||||
Showing{" "}
|
|
||||||
{filteredData.length === 0
|
|
||||||
? 0
|
|
||||||
: pagination.pageIndex * pagination.pageSize + 1}{" "}
|
|
||||||
to{" "}
|
|
||||||
{Math.min(
|
|
||||||
(pagination.pageIndex + 1) * pagination.pageSize,
|
|
||||||
filteredData.length,
|
|
||||||
)}{" "}
|
|
||||||
of {filteredData.length} entries
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8"
|
|
||||||
onClick={() => table.previousPage()}
|
|
||||||
disabled={!table.getCanPreviousPage()}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="size-4" />
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8"
|
|
||||||
onClick={() => table.nextPage()}
|
|
||||||
disabled={!table.getCanNextPage()}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<ChevronRight className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { inferRouterOutputs } from "@trpc/server";
|
|
||||||
import { ArrowRight, ListTodo, Loader2, XCircle } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import type { AppRouter } from "@/server/api/root";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
type QueueRow =
|
|
||||||
inferRouterOutputs<AppRouter>["deployment"]["queueList"][number];
|
|
||||||
|
|
||||||
const stateVariants: Record<
|
|
||||||
string,
|
|
||||||
| "default"
|
|
||||||
| "secondary"
|
|
||||||
| "destructive"
|
|
||||||
| "outline"
|
|
||||||
| "yellow"
|
|
||||||
| "green"
|
|
||||||
| "red"
|
|
||||||
> = {
|
|
||||||
pending: "secondary",
|
|
||||||
waiting: "secondary",
|
|
||||||
active: "yellow",
|
|
||||||
delayed: "outline",
|
|
||||||
completed: "green",
|
|
||||||
failed: "destructive",
|
|
||||||
cancelled: "outline",
|
|
||||||
paused: "outline",
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatTs(ts?: number): string {
|
|
||||||
if (ts == null) return "—";
|
|
||||||
const d = new Date(ts);
|
|
||||||
return d.toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getJobLabel(row: QueueRow): string {
|
|
||||||
const d = row.data as {
|
|
||||||
applicationType?: string;
|
|
||||||
applicationId?: string;
|
|
||||||
composeId?: string;
|
|
||||||
previewDeploymentId?: string;
|
|
||||||
titleLog?: string;
|
|
||||||
type?: string;
|
|
||||||
};
|
|
||||||
if (!d) return String(row.id);
|
|
||||||
const type = d.applicationType ?? "job";
|
|
||||||
const title = d.titleLog ?? "";
|
|
||||||
if (title) return title;
|
|
||||||
if (d.applicationId) return `Application ${d.applicationId.slice(0, 8)}…`;
|
|
||||||
if (d.composeId) return `Compose ${d.composeId.slice(0, 8)}…`;
|
|
||||||
if (d.previewDeploymentId)
|
|
||||||
return `Preview ${d.previewDeploymentId.slice(0, 8)}…`;
|
|
||||||
return `${type} ${String(row.id)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ShowQueueTable(props: { embedded?: boolean }) {
|
|
||||||
const { embedded: _embedded = false } = props;
|
|
||||||
const { data: queueList, isLoading } = api.deployment.queueList.useQuery(
|
|
||||||
undefined,
|
|
||||||
{ refetchInterval: 3000 },
|
|
||||||
);
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
|
||||||
const utils = api.useUtils();
|
|
||||||
const {
|
|
||||||
mutateAsync: cancelApplicationDeployment,
|
|
||||||
isPending: isCancellingApp,
|
|
||||||
} = api.application.cancelDeployment.useMutation({
|
|
||||||
onSuccess: () => void utils.deployment.queueList.invalidate(),
|
|
||||||
});
|
|
||||||
const {
|
|
||||||
mutateAsync: cancelComposeDeployment,
|
|
||||||
isPending: isCancellingCompose,
|
|
||||||
} = api.compose.cancelDeployment.useMutation({
|
|
||||||
onSuccess: () => void utils.deployment.queueList.invalidate(),
|
|
||||||
});
|
|
||||||
const isCancelling = isCancellingApp || isCancellingCompose;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="px-0">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex gap-4 w-full items-center justify-center min-h-[30vh] text-muted-foreground">
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
<span>Loading queue...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-md border overflow-x-auto">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Job ID</TableHead>
|
|
||||||
<TableHead>Label</TableHead>
|
|
||||||
<TableHead>Type</TableHead>
|
|
||||||
<TableHead>State</TableHead>
|
|
||||||
<TableHead>Added</TableHead>
|
|
||||||
<TableHead>Processed</TableHead>
|
|
||||||
<TableHead>Finished</TableHead>
|
|
||||||
<TableHead>Error</TableHead>
|
|
||||||
<TableHead className="w-[100px]">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{queueList?.length ? (
|
|
||||||
queueList.map((row) => {
|
|
||||||
const d = row.data as Record<string, unknown>;
|
|
||||||
const appType = d?.applicationType as string | undefined;
|
|
||||||
const pathInfo = row.servicePath;
|
|
||||||
const hasLink = pathInfo?.href != null;
|
|
||||||
return (
|
|
||||||
<TableRow key={String(row.id)}>
|
|
||||||
<TableCell className="font-mono text-xs">
|
|
||||||
{String(row.id)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="max-w-[200px] truncate">
|
|
||||||
{getJobLabel(row)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{appType ?? row.name ?? "—"}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={stateVariants[row.state] ?? "outline"}>
|
|
||||||
{row.state}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground text-xs">
|
|
||||||
{formatTs(row.timestamp)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground text-xs">
|
|
||||||
{formatTs(row.processedOn)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground text-xs">
|
|
||||||
{formatTs(row.finishedOn)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="max-w-[180px] truncate text-xs text-destructive">
|
|
||||||
{row.failedReason ?? "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{hasLink ? (
|
|
||||||
<Button variant="ghost" size="sm" asChild>
|
|
||||||
<Link href={pathInfo!.href!}>
|
|
||||||
<ArrowRight className="size-4 mr-1" />
|
|
||||||
Service
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
—
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isCloud &&
|
|
||||||
row.state === "active" &&
|
|
||||||
(d?.applicationId != null ||
|
|
||||||
d?.composeId != null) && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-destructive hover:text-destructive"
|
|
||||||
disabled={isCancelling}
|
|
||||||
onClick={() => {
|
|
||||||
const appId =
|
|
||||||
typeof d.applicationId === "string"
|
|
||||||
? d.applicationId
|
|
||||||
: undefined;
|
|
||||||
const compId =
|
|
||||||
typeof d.composeId === "string"
|
|
||||||
? d.composeId
|
|
||||||
: undefined;
|
|
||||||
if (appId) {
|
|
||||||
void cancelApplicationDeployment({
|
|
||||||
applicationId: appId,
|
|
||||||
});
|
|
||||||
} else if (compId) {
|
|
||||||
void cancelComposeDeployment({
|
|
||||||
composeId: compId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<XCircle className="size-4 mr-1" />
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={9} className="text-center py-12">
|
|
||||||
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground min-h-[30vh]">
|
|
||||||
<ListTodo className="size-8" />
|
|
||||||
<p className="font-medium">Queue is empty</p>
|
|
||||||
<p className="text-sm">
|
|
||||||
Deployment jobs will appear here when they are queued.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import copy from "copy-to-clipboard";
|
|
||||||
import {
|
|
||||||
Bot,
|
|
||||||
Check,
|
|
||||||
Copy,
|
|
||||||
Loader2,
|
|
||||||
RotateCcw,
|
|
||||||
Settings,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useState } from "react";
|
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import type { LogLine } from "./utils";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
logs: LogLine[];
|
|
||||||
context: "build" | "runtime";
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_LOG_LINES = 200;
|
|
||||||
|
|
||||||
export function AnalyzeLogs({ logs, context }: Props) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [aiId, setAiId] = useState<string>("");
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, {
|
|
||||||
enabled: open,
|
|
||||||
});
|
|
||||||
const { mutate, isPending, data, reset } = api.ai.analyzeLogs.useMutation({
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error("Analysis failed", {
|
|
||||||
description: error.message,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleAnalyze = () => {
|
|
||||||
if (!aiId || logs.length === 0) return;
|
|
||||||
|
|
||||||
const logsText = logs
|
|
||||||
.slice(-MAX_LOG_LINES)
|
|
||||||
.map((l) => l.message)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
mutate({ aiId, logs: logsText, context });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopy = () => {
|
|
||||||
if (!data?.analysis) return;
|
|
||||||
const success = copy(data.analysis);
|
|
||||||
if (success) {
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(isOpen) => {
|
|
||||||
setOpen(isOpen);
|
|
||||||
if (!isOpen) {
|
|
||||||
reset();
|
|
||||||
setAiId("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9"
|
|
||||||
disabled={logs.length === 0}
|
|
||||||
title="Analyze logs with AI"
|
|
||||||
>
|
|
||||||
<Bot className="mr-2 size-4" />
|
|
||||||
AI
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[550px] p-0" align="end">
|
|
||||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Bot className="h-4 w-4" />
|
|
||||||
<span className="text-sm font-medium">Log Analysis</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 space-y-3">
|
|
||||||
{!data?.analysis ? (
|
|
||||||
providers && providers.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center gap-3 py-2 text-center">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
No AI providers configured. Set up a provider to start
|
|
||||||
analyzing logs.
|
|
||||||
</p>
|
|
||||||
<Button size="sm" variant="outline" asChild>
|
|
||||||
<Link href="/dashboard/settings/ai">
|
|
||||||
<Settings className="mr-2 h-3.5 w-3.5" />
|
|
||||||
Configure AI Provider
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Select value={aiId} onValueChange={setAiId}>
|
|
||||||
<SelectTrigger className="h-9 text-sm">
|
|
||||||
<SelectValue placeholder="Select AI provider..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{providers?.map((p) => (
|
|
||||||
<SelectItem key={p.aiId} value={p.aiId}>
|
|
||||||
{p.name} ({p.model})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="w-full"
|
|
||||||
disabled={!aiId || isPending || logs.length === 0}
|
|
||||||
onClick={handleAnalyze}
|
|
||||||
>
|
|
||||||
{isPending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
|
||||||
Analyzing...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Bot className="mr-2 h-3.5 w-3.5" />
|
|
||||||
Analyze{" "}
|
|
||||||
{logs.length > MAX_LOG_LINES
|
|
||||||
? `last ${MAX_LOG_LINES}`
|
|
||||||
: logs.length}{" "}
|
|
||||||
lines
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="max-h-[400px] overflow-y-auto">
|
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none text-sm break-words">
|
|
||||||
<ReactMarkdown>{data.analysis}</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => {
|
|
||||||
reset();
|
|
||||||
handleAnalyze();
|
|
||||||
}}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
{isPending ? (
|
|
||||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
Re-analyze
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleCopy}
|
|
||||||
title="Copy analysis to clipboard"
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<Check className="h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
reset();
|
|
||||||
setAiId("");
|
|
||||||
}}
|
|
||||||
title="Change provider"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ import { AlertBlock } from "@/components/shared/alert-block";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { AnalyzeLogs } from "./analyze-logs";
|
|
||||||
import { LineCountFilter } from "./line-count-filter";
|
import { LineCountFilter } from "./line-count-filter";
|
||||||
import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter";
|
import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter";
|
||||||
import { StatusLogsFilter } from "./status-logs-filter";
|
import { StatusLogsFilter } from "./status-logs-filter";
|
||||||
@@ -347,13 +346,11 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
title={isPaused ? "Resume logs" : "Pause logs"}
|
title={isPaused ? "Resume logs" : "Pause logs"}
|
||||||
>
|
>
|
||||||
{isPaused ? (
|
{isPaused ? (
|
||||||
<Play className="size-4" />
|
<Play className="mr-2 h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<Pause className="size-4" />
|
<Pause className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
<span className="hidden lg:ml-2 lg:inline">
|
{isPaused ? "Resume" : "Pause"}
|
||||||
{isPaused ? "Resume" : "Pause"}
|
|
||||||
</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -364,13 +361,11 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
title="Copy logs to clipboard"
|
title="Copy logs to clipboard"
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<Check className="size-4" />
|
<Check className="mr-2 h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<Copy className="size-4" />
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
<span className="hidden lg:ml-2 lg:inline">
|
Copy
|
||||||
{copied ? "Copied" : "Copy"}
|
|
||||||
</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -378,18 +373,16 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
className="h-9 sm:w-auto w-full"
|
className="h-9 sm:w-auto w-full"
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
disabled={filteredLogs.length === 0 || !data?.Name}
|
disabled={filteredLogs.length === 0 || !data?.Name}
|
||||||
title="Download logs as text file"
|
|
||||||
>
|
>
|
||||||
<DownloadIcon className="size-4" />
|
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||||
<span className="hidden lg:ml-2 lg:inline">Download logs</span>
|
Download logs
|
||||||
</Button>
|
</Button>
|
||||||
<AnalyzeLogs logs={filteredLogs} context="runtime" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isPaused && (
|
{isPaused && (
|
||||||
<AlertBlock type="warning" className="items-center">
|
<AlertBlock type="warning">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Pause className="size-4" />
|
<Pause className="h-4 w-4" />
|
||||||
<span>
|
<span>
|
||||||
Logs paused
|
Logs paused
|
||||||
{messageBuffer.length > 0 && (
|
{messageBuffer.length > 0 && (
|
||||||
|
|||||||
@@ -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,112 +0,0 @@
|
|||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
containerId: string;
|
|
||||||
serverId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Mount {
|
|
||||||
Type: string;
|
|
||||||
Source: string;
|
|
||||||
Destination: string;
|
|
||||||
Mode: string;
|
|
||||||
RW: boolean;
|
|
||||||
Propagation: string;
|
|
||||||
Name?: string;
|
|
||||||
Driver?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ShowContainerMounts = ({ containerId, serverId }: Props) => {
|
|
||||||
const { data } = api.docker.getConfig.useQuery(
|
|
||||||
{
|
|
||||||
containerId,
|
|
||||||
serverId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!containerId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const mounts: Mount[] = data?.Mounts ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="w-full cursor-pointer"
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
View Mounts
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Container Mounts</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Volume and bind mounts for this container
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="overflow-auto max-h-[70vh]">
|
|
||||||
{mounts.length === 0 ? (
|
|
||||||
<div className="text-center text-muted-foreground py-8">
|
|
||||||
No mounts found for this container.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Type</TableHead>
|
|
||||||
<TableHead>Source</TableHead>
|
|
||||||
<TableHead>Destination</TableHead>
|
|
||||||
<TableHead>Mode</TableHead>
|
|
||||||
<TableHead>Read/Write</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{mounts.map((mount, index) => (
|
|
||||||
<TableRow key={index}>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline">{mount.Type}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs max-w-[250px] truncate">
|
|
||||||
{mount.Name || mount.Source}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs max-w-[250px] truncate">
|
|
||||||
{mount.Destination}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs">
|
|
||||||
{mount.Mode || "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant={mount.RW ? "default" : "secondary"}>
|
|
||||||
{mount.RW ? "RW" : "RO"}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
containerId: string;
|
|
||||||
serverId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Network {
|
|
||||||
IPAMConfig: unknown;
|
|
||||||
Links: unknown;
|
|
||||||
Aliases: string[] | null;
|
|
||||||
MacAddress: string;
|
|
||||||
NetworkID: string;
|
|
||||||
EndpointID: string;
|
|
||||||
Gateway: string;
|
|
||||||
IPAddress: string;
|
|
||||||
IPPrefixLen: number;
|
|
||||||
IPv6Gateway: string;
|
|
||||||
GlobalIPv6Address: string;
|
|
||||||
GlobalIPv6PrefixLen: number;
|
|
||||||
DriverOpts: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ShowContainerNetworks = ({ containerId, serverId }: Props) => {
|
|
||||||
const { data } = api.docker.getConfig.useQuery(
|
|
||||||
{
|
|
||||||
containerId,
|
|
||||||
serverId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!containerId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const networks: Record<string, Network> =
|
|
||||||
data?.NetworkSettings?.Networks ?? {};
|
|
||||||
const entries = Object.entries(networks);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="w-full cursor-pointer"
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
View Networks
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Container Networks</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Networks attached to this container
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="overflow-auto max-h-[70vh]">
|
|
||||||
{entries.length === 0 ? (
|
|
||||||
<div className="text-center text-muted-foreground py-8">
|
|
||||||
No networks found for this container.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Network</TableHead>
|
|
||||||
<TableHead>IP Address</TableHead>
|
|
||||||
<TableHead>Gateway</TableHead>
|
|
||||||
<TableHead>MAC Address</TableHead>
|
|
||||||
<TableHead>Aliases</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{entries.map(([name, network]) => (
|
|
||||||
<TableRow key={name}>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline">{name}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs">
|
|
||||||
{network.IPAddress
|
|
||||||
? `${network.IPAddress}/${network.IPPrefixLen}`
|
|
||||||
: "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs">
|
|
||||||
{network.Gateway || "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs">
|
|
||||||
{network.MacAddress || "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs">
|
|
||||||
{network.Aliases?.join(", ") || "-"}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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,11 +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 { ShowContainerMounts } from "../mounts/show-container-mounts";
|
|
||||||
import { ShowContainerNetworks } from "../networks/show-container-networks";
|
|
||||||
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>[] = [
|
||||||
@@ -125,30 +121,12 @@ export const columns: ColumnDef<Container>[] = [
|
|||||||
containerId={container.containerId}
|
containerId={container.containerId}
|
||||||
serverId={container.serverId || ""}
|
serverId={container.serverId || ""}
|
||||||
/>
|
/>
|
||||||
<ShowContainerMounts
|
|
||||||
containerId={container.containerId}
|
|
||||||
serverId={container.serverId || ""}
|
|
||||||
/>
|
|
||||||
<ShowContainerNetworks
|
|
||||||
containerId={container.containerId}
|
|
||||||
serverId={container.serverId || ""}
|
|
||||||
/>
|
|
||||||
<DockerTerminalModal
|
<DockerTerminalModal
|
||||||
containerId={container.containerId}
|
containerId={container.containerId}
|
||||||
serverId={container.serverId || ""}
|
serverId={container.serverId || ""}
|
||||||
>
|
>
|
||||||
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 {
|
|
||||||
type UploadFileToContainer,
|
|
||||||
uploadFileToContainerSchema,
|
|
||||||
} 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,291 +0,0 @@
|
|||||||
import { formatDistanceToNow } from "date-fns";
|
|
||||||
import { ArrowRight, Rocket, Server } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card } from "@/components/ui/card";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
type DeploymentStatus = "idle" | "running" | "done" | "error";
|
|
||||||
|
|
||||||
const statusDotClass: Record<string, string> = {
|
|
||||||
done: "bg-emerald-500",
|
|
||||||
running: "bg-amber-500",
|
|
||||||
error: "bg-red-500",
|
|
||||||
idle: "bg-muted-foreground/40",
|
|
||||||
};
|
|
||||||
|
|
||||||
function getServiceInfo(d: any) {
|
|
||||||
const app = d.application;
|
|
||||||
const comp = d.compose;
|
|
||||||
const serverName: string =
|
|
||||||
d.server?.name ?? app?.server?.name ?? comp?.server?.name ?? "Dokploy";
|
|
||||||
if (app?.environment?.project && app.environment) {
|
|
||||||
return {
|
|
||||||
name: app.name as string,
|
|
||||||
environment: app.environment.name as string,
|
|
||||||
projectName: app.environment.project.name as string,
|
|
||||||
serverName,
|
|
||||||
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (comp?.environment?.project && comp.environment) {
|
|
||||||
return {
|
|
||||||
name: comp.name as string,
|
|
||||||
environment: comp.environment.name as string,
|
|
||||||
projectName: comp.environment.project.name as string,
|
|
||||||
serverName,
|
|
||||||
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
delta,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
delta?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between">
|
|
||||||
<span className="text-xs uppercase tracking-wider text-muted-foreground">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-3xl font-semibold tracking-tight">{value}</span>
|
|
||||||
{delta && (
|
|
||||||
<span className="text-xs text-muted-foreground">{delta}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusListCard({
|
|
||||||
label,
|
|
||||||
items,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
items: { dotClass: string; label: string; count: number }[];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col gap-3">
|
|
||||||
<span className="text-xs uppercase tracking-wider text-muted-foreground">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<ul className="flex flex-col gap-1.5">
|
|
||||||
{items.map((item) => (
|
|
||||||
<li key={item.label} className="flex items-center gap-2.5 text-sm">
|
|
||||||
<span
|
|
||||||
className={`size-2 rounded-full shrink-0 ${item.dotClass}`}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<span className="font-semibold tabular-nums w-8">{item.count}</span>
|
|
||||||
<span className="text-muted-foreground">{item.label}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ShowHome = () => {
|
|
||||||
const { data: auth } = api.user.get.useQuery();
|
|
||||||
const { data: homeStats } = api.project.homeStats.useQuery();
|
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
|
||||||
const canReadDeployments = !!permissions?.deployment.read;
|
|
||||||
const { data: deployments } = api.deployment.allCentralized.useQuery(
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
enabled: canReadDeployments,
|
|
||||||
refetchInterval: 10000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const firstName = auth?.user?.firstName?.trim();
|
|
||||||
|
|
||||||
const totals = homeStats ?? {
|
|
||||||
projects: 0,
|
|
||||||
environments: 0,
|
|
||||||
applications: 0,
|
|
||||||
compose: 0,
|
|
||||||
databases: 0,
|
|
||||||
services: 0,
|
|
||||||
};
|
|
||||||
const statusBreakdown = homeStats?.status ?? {
|
|
||||||
running: 0,
|
|
||||||
error: 0,
|
|
||||||
idle: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const recentDeployments = useMemo(() => {
|
|
||||||
if (!deployments) return [];
|
|
||||||
return [...deployments]
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
|
||||||
)
|
|
||||||
.slice(0, 10);
|
|
||||||
}, [deployments]);
|
|
||||||
|
|
||||||
const deployStats = useMemo(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
const weekMs = 7 * 24 * 60 * 60 * 1000;
|
|
||||||
const lastStart = now - weekMs;
|
|
||||||
const prevStart = now - 2 * weekMs;
|
|
||||||
|
|
||||||
const last: NonNullable<typeof deployments> = [];
|
|
||||||
const prev: NonNullable<typeof deployments> = [];
|
|
||||||
for (const d of deployments ?? []) {
|
|
||||||
const t = new Date(d.createdAt).getTime();
|
|
||||||
if (t >= lastStart) last.push(d);
|
|
||||||
else if (t >= prevStart) prev.push(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastCount = last.length;
|
|
||||||
const prevCount = prev.length;
|
|
||||||
let delta: string | undefined;
|
|
||||||
if (prevCount > 0) {
|
|
||||||
const pct = Math.round(((lastCount - prevCount) / prevCount) * 100);
|
|
||||||
delta = `${pct >= 0 ? "+" : ""}${pct}% vs prev 7d`;
|
|
||||||
} else if (lastCount > 0) {
|
|
||||||
delta = "no prior data";
|
|
||||||
} else {
|
|
||||||
delta = "no activity yet";
|
|
||||||
}
|
|
||||||
|
|
||||||
return { value: String(lastCount), delta };
|
|
||||||
}, [deployments]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl min-h-[85vh]">
|
|
||||||
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-6 h-full">
|
|
||||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
|
|
||||||
<h1 className="text-3xl font-semibold tracking-tight">
|
|
||||||
{firstName ? `Welcome back, ${firstName}` : "Welcome back"}
|
|
||||||
</h1>
|
|
||||||
<Button asChild variant="secondary" className="w-fit">
|
|
||||||
<Link href="/dashboard/projects">
|
|
||||||
Go to projects
|
|
||||||
<ArrowRight className="size-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<StatCard
|
|
||||||
label="Projects"
|
|
||||||
value={String(totals.projects)}
|
|
||||||
delta={`${totals.environments} ${totals.environments === 1 ? "environment" : "environments"}`}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Services"
|
|
||||||
value={String(totals.services)}
|
|
||||||
delta={`${totals.applications} apps · ${totals.compose} compose · ${totals.databases} db`}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Deploys / 7d"
|
|
||||||
value={deployStats.value}
|
|
||||||
delta={deployStats.delta}
|
|
||||||
/>
|
|
||||||
<StatusListCard
|
|
||||||
label="Status"
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
dotClass: "bg-emerald-500",
|
|
||||||
label: "running",
|
|
||||||
count: statusBreakdown.running,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
dotClass: "bg-red-500",
|
|
||||||
label: "errored",
|
|
||||||
count: statusBreakdown.error,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
dotClass: "bg-muted-foreground/40",
|
|
||||||
label: "idle",
|
|
||||||
count: statusBreakdown.idle,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border bg-background">
|
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Rocket className="size-4 text-muted-foreground" />
|
|
||||||
<h2 className="text-sm font-semibold">Recent deployments</h2>
|
|
||||||
</div>
|
|
||||||
{canReadDeployments && (
|
|
||||||
<Link
|
|
||||||
href="/dashboard/deployments"
|
|
||||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
view all →
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!canReadDeployments ? (
|
|
||||||
<div className="min-h-[400px] flex flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground p-10">
|
|
||||||
<Rocket className="size-8 opacity-40" />
|
|
||||||
<span>You do not have permission to view deployments.</span>
|
|
||||||
</div>
|
|
||||||
) : recentDeployments.length === 0 ? (
|
|
||||||
<div className="min-h-[400px] flex flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground p-10">
|
|
||||||
<Rocket className="size-8 opacity-40" />
|
|
||||||
<span>No deployments yet.</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y">
|
|
||||||
{recentDeployments.map((d) => {
|
|
||||||
const info = getServiceInfo(d);
|
|
||||||
if (!info) return null;
|
|
||||||
const status = (d.status ?? "idle") as DeploymentStatus;
|
|
||||||
return (
|
|
||||||
<li key={d.deploymentId}>
|
|
||||||
<Link
|
|
||||||
href={info.href}
|
|
||||||
className="flex items-center gap-4 px-5 py-4 hover:bg-muted/40 transition-colors"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`size-2 rounded-full shrink-0 ${statusDotClass[status] ?? statusDotClass.idle}`}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col min-w-0 flex-1">
|
|
||||||
<span className="text-sm truncate">{info.name}</span>
|
|
||||||
<span className="text-xs text-muted-foreground truncate">
|
|
||||||
{info.projectName} · {info.environment}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted-foreground w-36 hidden lg:flex items-center justify-end gap-1.5 truncate">
|
|
||||||
<Server className="size-3 shrink-0" />
|
|
||||||
<span className="truncate">{info.serverName}</span>
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground w-20 text-right hidden sm:inline">
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground w-24 text-right hidden md:inline">
|
|
||||||
{formatDistanceToNow(new Date(d.createdAt), {
|
|
||||||
addSuffix: true,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground hover:text-foreground transition-colors">
|
|
||||||
logs →
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user