mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-01 20:15:29 +02:00
Compare commits
23 Commits
v0.29.3
...
feature/ma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
549b124fcd | ||
|
|
0c5da0b36f | ||
|
|
ee18724dd7 | ||
|
|
1ae9b4025c | ||
|
|
6e342ee2f2 | ||
|
|
ef0cf9bd02 | ||
|
|
8d88a34a64 | ||
|
|
a50f958a6f | ||
|
|
1fdbe87d84 | ||
|
|
67278d8783 | ||
|
|
aff200f84f | ||
|
|
558d809871 | ||
|
|
f8fcf68909 | ||
|
|
7a568aadac | ||
|
|
63e33a29cc | ||
|
|
754774ea02 | ||
|
|
a714e0f83f | ||
|
|
9f10f0f4e9 | ||
|
|
b109e0ebc4 | ||
|
|
282d358d04 | ||
|
|
2f08b33931 | ||
|
|
5bc870dc2d | ||
|
|
299950a323 |
79
.github/workflows/dokploy.yml
vendored
79
.github/workflows/dokploy.yml
vendored
@@ -138,6 +138,8 @@ 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
|
||||||
@@ -160,3 +162,80 @@ 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 }}"
|
||||||
|
|||||||
21
.github/workflows/sync-openapi-docs.yml
vendored
21
.github/workflows/sync-openapi-docs.yml
vendored
@@ -110,3 +110,24 @@ jobs:
|
|||||||
|
|
||||||
echo "✅ OpenAPI synced to CLI repository successfully"
|
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"
|
||||||
|
|
||||||
|
|||||||
83
.github/workflows/sync-version.yml
vendored
83
.github/workflows/sync-version.yml
vendored
@@ -1,83 +0,0 @@
|
|||||||
name: Sync version to MCP and CLI repos
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
sync-version:
|
|
||||||
name: Sync version to external repos
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Dokploy repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Get version
|
|
||||||
id: get_version
|
|
||||||
run: |
|
|
||||||
VERSION=$(jq -r .version apps/dokploy/package.json | sed 's/^v//')
|
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
echo "Version: $VERSION"
|
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
# Regenerate tools from latest OpenAPI spec
|
|
||||||
npm install -g pnpm
|
|
||||||
pnpm install
|
|
||||||
pnpm run fetch-openapi
|
|
||||||
pnpm run generate
|
|
||||||
|
|
||||||
# Bump version after install so pnpm install doesn't overwrite it
|
|
||||||
jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp
|
|
||||||
mv package.json.tmp package.json
|
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
|
||||||
git config user.email "bot@dokploy.com"
|
|
||||||
|
|
||||||
git add -A
|
|
||||||
git commit -m "chore: bump version to ${{ steps.get_version.outputs.version }}" \
|
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
|
||||||
-m "Release: ${{ github.event.release.html_url }}" \
|
|
||||||
--allow-empty
|
|
||||||
|
|
||||||
git push
|
|
||||||
|
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
# Copy latest openapi spec and regenerate commands
|
|
||||||
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
|
||||||
npm install -g pnpm
|
|
||||||
pnpm install
|
|
||||||
pnpm run generate
|
|
||||||
|
|
||||||
# Bump version after install so pnpm install doesn't overwrite it
|
|
||||||
if [ -f package.json ]; then
|
|
||||||
jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp
|
|
||||||
mv package.json.tmp package.json
|
|
||||||
fi
|
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
|
||||||
git config user.email "bot@dokploy.com"
|
|
||||||
|
|
||||||
git add -A
|
|
||||||
git commit -m "chore: bump version to ${{ steps.get_version.outputs.version }}" \
|
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
|
||||||
-m "Release: ${{ github.event.release.html_url }}" \
|
|
||||||
--allow-empty
|
|
||||||
|
|
||||||
git push
|
|
||||||
|
|
||||||
echo "CLI repo synced to version ${{ steps.get_version.outputs.version }}"
|
|
||||||
|
|
||||||
8403
api-1.json
Normal file
8403
api-1.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,6 @@
|
|||||||
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
|
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
|
||||||
PORT=3000
|
PORT=3000
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Managed Servers (Dokploy Cloud only) — API token from https://hpanel.hostinger.com/profile/api
|
||||||
|
HOSTINGER_API_KEY=
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function AnalyzeLogs({ logs, context }: Props) {
|
|||||||
disabled={logs.length === 0}
|
disabled={logs.length === 0}
|
||||||
title="Analyze logs with AI"
|
title="Analyze logs with AI"
|
||||||
>
|
>
|
||||||
<Bot className="mr-2 h-4 w-4" />
|
<Bot className="mr-2 size-4" />
|
||||||
AI
|
AI
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|||||||
@@ -347,11 +347,13 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
title={isPaused ? "Resume logs" : "Pause logs"}
|
title={isPaused ? "Resume logs" : "Pause logs"}
|
||||||
>
|
>
|
||||||
{isPaused ? (
|
{isPaused ? (
|
||||||
<Play className="mr-2 h-4 w-4" />
|
<Play className="size-4" />
|
||||||
) : (
|
) : (
|
||||||
<Pause className="mr-2 h-4 w-4" />
|
<Pause className="size-4" />
|
||||||
)}
|
)}
|
||||||
{isPaused ? "Resume" : "Pause"}
|
<span className="hidden lg:ml-2 lg:inline">
|
||||||
|
{isPaused ? "Resume" : "Pause"}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -362,11 +364,13 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
title="Copy logs to clipboard"
|
title="Copy logs to clipboard"
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<Check className="mr-2 h-4 w-4" />
|
<Check className="size-4" />
|
||||||
) : (
|
) : (
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="size-4" />
|
||||||
)}
|
)}
|
||||||
Copy
|
<span className="hidden lg:ml-2 lg:inline">
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -374,17 +378,18 @@ 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="mr-2 h-4 w-4" />
|
<DownloadIcon className="size-4" />
|
||||||
Download logs
|
<span className="hidden lg:ml-2 lg:inline">Download logs</span>
|
||||||
</Button>
|
</Button>
|
||||||
<AnalyzeLogs logs={filteredLogs} context="runtime" />
|
<AnalyzeLogs logs={filteredLogs} context="runtime" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isPaused && (
|
{isPaused && (
|
||||||
<AlertBlock type="warning">
|
<AlertBlock type="warning" className="items-center">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Pause className="h-4 w-4" />
|
<Pause className="size-4" />
|
||||||
<span>
|
<span>
|
||||||
Logs paused
|
Logs paused
|
||||||
{messageBuffer.length > 0 && (
|
{messageBuffer.length > 0 && (
|
||||||
|
|||||||
494
apps/dokploy/components/dashboard/project/add-import.tsx
Normal file
494
apps/dokploy/components/dashboard/project/add-import.tsx
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import { Code2, FileInput, Globe2, HardDrive, HelpCircle } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { slugify } from "@/lib/slug";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
|
||||||
|
|
||||||
|
const AddImportSchema = z.object({
|
||||||
|
name: z.string().min(1, { message: "Name is required" }),
|
||||||
|
appName: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "App name is required" })
|
||||||
|
.regex(APP_NAME_REGEX, { message: APP_NAME_MESSAGE }),
|
||||||
|
base64: z.string().min(1, { message: "Base64 content is required" }),
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type AddImport = z.infer<typeof AddImportSchema>;
|
||||||
|
|
||||||
|
type TemplateInfo = {
|
||||||
|
compose: string;
|
||||||
|
template: {
|
||||||
|
domains: Array<{
|
||||||
|
serviceName: string;
|
||||||
|
port: number;
|
||||||
|
path?: string;
|
||||||
|
host?: string;
|
||||||
|
}>;
|
||||||
|
envs: string[];
|
||||||
|
mounts: Array<{ filePath: string; content: string }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
environmentId: string;
|
||||||
|
projectName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddImport = ({ environmentId, projectName }: Props) => {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
|
const [mountOpen, setMountOpen] = useState(false);
|
||||||
|
const [selectedMount, setSelectedMount] = useState<{
|
||||||
|
filePath: string;
|
||||||
|
content: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [templateInfo, setTemplateInfo] = useState<TemplateInfo | null>(null);
|
||||||
|
|
||||||
|
const slug = slugify(projectName);
|
||||||
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||||
|
const shouldShowServerDropdown = !!(servers && servers.length > 0);
|
||||||
|
|
||||||
|
const { mutateAsync: previewTemplate, isPending: isProcessing } =
|
||||||
|
api.compose.previewTemplate.useMutation();
|
||||||
|
const { mutateAsync: createCompose, isPending: isCreating } =
|
||||||
|
api.compose.create.useMutation();
|
||||||
|
const { mutateAsync: importCompose, isPending: isImporting } =
|
||||||
|
api.compose.import.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<AddImport>({
|
||||||
|
defaultValues: { name: "", appName: `${slug}-`, base64: "" },
|
||||||
|
resolver: zodResolver(AddImportSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetAll = () => {
|
||||||
|
form.reset({ name: "", appName: `${slug}-`, base64: "" });
|
||||||
|
setTemplateInfo(null);
|
||||||
|
setPreviewOpen(false);
|
||||||
|
setMountOpen(false);
|
||||||
|
setSelectedMount(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
if (!open) resetAll();
|
||||||
|
setVisible(open);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoad = async (data: AddImport) => {
|
||||||
|
try {
|
||||||
|
const result = await previewTemplate({
|
||||||
|
appName: data.appName,
|
||||||
|
base64: data.base64.trim(),
|
||||||
|
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
|
||||||
|
});
|
||||||
|
setTemplateInfo(result);
|
||||||
|
setPreviewOpen(true);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error processing template",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
const data = form.getValues();
|
||||||
|
try {
|
||||||
|
const compose = await createCompose({
|
||||||
|
name: data.name,
|
||||||
|
appName: data.appName,
|
||||||
|
environmentId,
|
||||||
|
composeType: "docker-compose",
|
||||||
|
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
|
||||||
|
});
|
||||||
|
await importCompose({
|
||||||
|
composeId: compose.composeId,
|
||||||
|
base64: data.base64.trim(),
|
||||||
|
});
|
||||||
|
toast.success("Compose imported successfully");
|
||||||
|
await utils.environment.one.invalidate({ environmentId });
|
||||||
|
resetAll();
|
||||||
|
setVisible(false);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error importing compose",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelPreview = () => {
|
||||||
|
setPreviewOpen(false);
|
||||||
|
setTemplateInfo(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={visible} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogTrigger className="w-full">
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="w-full cursor-pointer space-x-3"
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<FileInput className="size-4 text-muted-foreground" />
|
||||||
|
<span>Import</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Import Compose</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Paste a base64-encoded compose export to preview and import it
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="hook-form-import"
|
||||||
|
onSubmit={form.handleSubmit(handleLoad)}
|
||||||
|
className="grid w-full gap-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="My App"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value || "";
|
||||||
|
form.setValue(
|
||||||
|
"appName",
|
||||||
|
`${slug}-${slugify(val.trim())}`,
|
||||||
|
);
|
||||||
|
field.onChange(val);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{shouldShowServerDropdown && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="serverId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
|
||||||
|
Select a Server {!isCloud ? "(Optional)" : ""}
|
||||||
|
<HelpCircle className="size-4 text-muted-foreground" />
|
||||||
|
</FormLabel>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
className="z-[999] w-[300px]"
|
||||||
|
align="start"
|
||||||
|
side="top"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
If no server is selected, the compose will be
|
||||||
|
deployed on the server where the user is logged
|
||||||
|
in.
|
||||||
|
</span>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={
|
||||||
|
field.value || (!isCloud ? "dokploy" : undefined)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
!isCloud ? "Dokploy" : "Select a Server"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{!isCloud && (
|
||||||
|
<SelectItem value="dokploy">
|
||||||
|
<span className="flex items-center gap-2 justify-between w-full">
|
||||||
|
<span>Dokploy</span>
|
||||||
|
<span className="text-muted-foreground text-xs self-center">
|
||||||
|
Default
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
{servers?.map((server) => (
|
||||||
|
<SelectItem
|
||||||
|
key={server.serverId}
|
||||||
|
value={server.serverId}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 justify-between w-full">
|
||||||
|
<span>{server.name}</span>
|
||||||
|
<span className="text-muted-foreground text-xs self-center">
|
||||||
|
{server.ipAddress}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectLabel>
|
||||||
|
Servers (
|
||||||
|
{(servers?.length ?? 0) + (!isCloud ? 1 : 0)})
|
||||||
|
</SelectLabel>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="appName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>App Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="my-app" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="base64"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Configuration (Base64)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Paste your base64-encoded compose export here..."
|
||||||
|
className="font-mono resize-none h-32"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
isLoading={isCreating || isProcessing}
|
||||||
|
>
|
||||||
|
Load
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Preview modal */}
|
||||||
|
<Dialog
|
||||||
|
open={previewOpen}
|
||||||
|
onOpenChange={(open) => !open && handleCancelPreview()}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-[60vw]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl font-bold">
|
||||||
|
Template Information
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="space-y-2">
|
||||||
|
<p>Review the template information before importing</p>
|
||||||
|
<AlertBlock type="warning">
|
||||||
|
Warning: This will remove all existing environment variables,
|
||||||
|
mounts, and domains from this service.
|
||||||
|
</AlertBlock>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Code2 className="h-5 w-5 text-primary" />
|
||||||
|
<h3 className="text-lg font-semibold">Docker Compose</h3>
|
||||||
|
</div>
|
||||||
|
<CodeEditor
|
||||||
|
language="yaml"
|
||||||
|
value={templateInfo?.compose || ""}
|
||||||
|
className="font-mono"
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{templateInfo?.template.domains &&
|
||||||
|
templateInfo.template.domains.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Globe2 className="h-5 w-5 text-primary" />
|
||||||
|
<h3 className="text-lg font-semibold">Domains</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
{templateInfo.template.domains.map((domain, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="rounded-lg border bg-card p-3 text-card-foreground shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="font-medium">
|
||||||
|
{domain.serviceName}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<div>Port: {domain.port}</div>
|
||||||
|
{domain.host && <div>Host: {domain.host}</div>}
|
||||||
|
{domain.path && <div>Path: {domain.path}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{templateInfo?.template.envs &&
|
||||||
|
templateInfo.template.envs.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Code2 className="h-5 w-5 text-primary" />
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
Environment Variables
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{templateInfo.template.envs.map((env, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="rounded-lg truncate border bg-card p-2 font-mono text-sm"
|
||||||
|
>
|
||||||
|
{env}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{templateInfo?.template.mounts &&
|
||||||
|
templateInfo.template.mounts.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HardDrive className="h-5 w-5 text-primary" />
|
||||||
|
<h3 className="text-lg font-semibold">Mounts</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{templateInfo.template.mounts.map((mount, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="rounded-lg border bg-card p-2 font-mono text-sm hover:bg-accent cursor-pointer transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedMount(mount);
|
||||||
|
setMountOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mount.filePath}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<Button variant="outline" onClick={handleCancelPreview}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button isLoading={isImporting} onClick={handleImport}>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Mount content modal */}
|
||||||
|
<Dialog open={mountOpen} onOpenChange={setMountOpen}>
|
||||||
|
<DialogContent className="max-w-[50vw]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-bold">
|
||||||
|
{selectedMount?.filePath}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>Mount File Content</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<ScrollArea className="h-[45vh] pr-4">
|
||||||
|
<CodeEditor
|
||||||
|
language="yaml"
|
||||||
|
value={selectedMount?.content || ""}
|
||||||
|
className="font-mono"
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</ScrollArea>
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<Button onClick={() => setMountOpen(false)}>Close</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CreditCard, FileText } from "lucide-react";
|
import { CreditCard, FileText, Server } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +17,11 @@ const navigationItems = [
|
|||||||
href: "/dashboard/settings/billing",
|
href: "/dashboard/settings/billing",
|
||||||
icon: CreditCard,
|
icon: CreditCard,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Managed Servers",
|
||||||
|
href: "/dashboard/settings/managed-servers",
|
||||||
|
icon: Server,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Invoices",
|
name: "Invoices",
|
||||||
href: "/dashboard/settings/invoices",
|
href: "/dashboard/settings/invoices",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
MinusIcon,
|
MinusIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
Server,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -82,6 +83,11 @@ const navigationItems = [
|
|||||||
href: "/dashboard/settings/billing",
|
href: "/dashboard/settings/billing",
|
||||||
icon: CreditCard,
|
icon: CreditCard,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Managed Servers",
|
||||||
|
href: "/dashboard/settings/managed-servers",
|
||||||
|
icon: Server,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Invoices",
|
name: "Invoices",
|
||||||
href: "/dashboard/settings/invoices",
|
href: "/dashboard/settings/invoices",
|
||||||
|
|||||||
@@ -0,0 +1,493 @@
|
|||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
CreditCard,
|
||||||
|
ExternalLink,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Server,
|
||||||
|
Trash2,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
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 { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const navigationItems = [
|
||||||
|
{
|
||||||
|
name: "Subscription",
|
||||||
|
href: "/dashboard/settings/billing",
|
||||||
|
icon: CreditCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Managed Servers",
|
||||||
|
href: "/dashboard/settings/managed-servers",
|
||||||
|
icon: Server,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invoices",
|
||||||
|
href: "/dashboard/settings/invoices",
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
variant: "default" | "secondary" | "destructive" | "outline";
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
pending: {
|
||||||
|
label: "Pending",
|
||||||
|
icon: <Clock className="size-3" />,
|
||||||
|
variant: "secondary",
|
||||||
|
},
|
||||||
|
provisioning: {
|
||||||
|
label: "Provisioning",
|
||||||
|
icon: <Loader2 className="size-3 animate-spin" />,
|
||||||
|
variant: "secondary",
|
||||||
|
},
|
||||||
|
configuring: {
|
||||||
|
label: "Installing Dokploy",
|
||||||
|
icon: <Loader2 className="size-3 animate-spin" />,
|
||||||
|
variant: "secondary",
|
||||||
|
},
|
||||||
|
ready: {
|
||||||
|
label: "Ready",
|
||||||
|
icon: <CheckCircle2 className="size-3" />,
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
label: "Error",
|
||||||
|
icon: <XCircle className="size-3" />,
|
||||||
|
variant: "destructive",
|
||||||
|
},
|
||||||
|
terminating: {
|
||||||
|
label: "Terminating",
|
||||||
|
icon: <Loader2 className="size-3 animate-spin" />,
|
||||||
|
variant: "secondary",
|
||||||
|
},
|
||||||
|
terminated: {
|
||||||
|
label: "Terminated",
|
||||||
|
icon: <AlertCircle className="size-3" />,
|
||||||
|
variant: "outline",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatSpecs(cpus: number, memoryMb: number, diskMb: number, bandwidthMb: number) {
|
||||||
|
const bandwidthTb = bandwidthMb / 1024 / 1024;
|
||||||
|
const bandwidthLabel = bandwidthTb >= 1 ? `${bandwidthTb.toFixed(0)} TB` : `${Math.round(bandwidthMb / 1024)} GB`;
|
||||||
|
return `${cpus} vCPU · ${Math.round(memoryMb / 1024)} GB RAM · ${Math.round(diskMb / 1024)} GB NVMe · ${bandwidthLabel} bandwidth`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function centsToDisplay(cents: number) {
|
||||||
|
return (cents / 100).toFixed(2).replace(/\.00$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrderServerDialog({ onSuccess }: { onSuccess: () => void }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selectedPlan, setSelectedPlan] = useState<string>("");
|
||||||
|
const [selectedDc, setSelectedDc] = useState<string>("");
|
||||||
|
const [isAnnual, setIsAnnual] = useState(false);
|
||||||
|
|
||||||
|
const { data: plans, isLoading: loadingPlans } =
|
||||||
|
api.managedServer.getPlans.useQuery(undefined, { enabled: open });
|
||||||
|
const { data: dataCenters, isLoading: loadingDcs } =
|
||||||
|
api.managedServer.getDataCenters.useQuery(undefined, { enabled: open });
|
||||||
|
|
||||||
|
const isLoadingOptions = loadingPlans || loadingDcs;
|
||||||
|
|
||||||
|
const purchase = api.managedServer.purchase.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Server order placed! Provisioning will take ~5 minutes.");
|
||||||
|
setOpen(false);
|
||||||
|
onSuccess();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const plan = plans?.find((p) => p.id === selectedPlan);
|
||||||
|
|
||||||
|
const displayPrice = (p: NonNullable<typeof plan>) =>
|
||||||
|
isAnnual
|
||||||
|
? `$${centsToDisplay(p.dokployPriceCentsAnnual)}/yr`
|
||||||
|
: `$${centsToDisplay(p.dokployPriceCentsMonthly)}/mo`;
|
||||||
|
|
||||||
|
const displayPriceSmall = (p: NonNullable<typeof plan>) =>
|
||||||
|
isAnnual
|
||||||
|
? `$${centsToDisplay(Math.round(p.dokployPriceCentsAnnual / 12))}/mo billed annually`
|
||||||
|
: `$${centsToDisplay(p.dokployPriceCentsAnnual)}/yr if annual`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm">
|
||||||
|
<Plus className="size-4 mr-2" />
|
||||||
|
Order Server
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Order a Managed Server</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
We'll provision and configure a server for you automatically. Ready
|
||||||
|
in ~5 minutes.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 pt-2">
|
||||||
|
{isLoadingOptions ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 gap-3 text-muted-foreground">
|
||||||
|
<Loader2 className="size-6 animate-spin" />
|
||||||
|
<p className="text-sm">Loading available plans...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Billing period toggle */}
|
||||||
|
<div className="flex items-center gap-1 rounded-lg border p-1 bg-muted/40 w-fit">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsAnnual(false)}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-1.5 rounded-md text-sm font-medium transition-colors",
|
||||||
|
!isAnnual
|
||||||
|
? "bg-background shadow-sm text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Monthly
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsAnnual(true)}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-1.5 rounded-md text-sm font-medium transition-colors flex items-center gap-1.5",
|
||||||
|
isAnnual
|
||||||
|
? "bg-background shadow-sm text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Annual
|
||||||
|
<span className="text-xs bg-green-500/15 text-green-600 dark:text-green-400 px-1.5 py-0.5 rounded font-semibold">
|
||||||
|
Save ~20%
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plan selector */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Plan</Label>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{plans?.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedPlan(p.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between rounded-lg border p-3 text-left transition-colors",
|
||||||
|
selectedPlan === p.id
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:border-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{p.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatSpecs(p.cpus, p.memoryMb, p.diskMb, p.bandwidthMb)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-semibold text-sm">
|
||||||
|
{displayPrice(p)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{displayPriceSmall(p)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data center selector */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Data Center</Label>
|
||||||
|
<Select value={selectedDc} onValueChange={setSelectedDc}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a location..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent position="popper" side="bottom" sideOffset={4} className="max-h-56 overflow-y-auto">
|
||||||
|
{dataCenters?.map((dc) => (
|
||||||
|
<SelectItem key={dc.id} value={String(dc.id)}>
|
||||||
|
{dc.city} — {dc.continent}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{plan && selectedDc && (
|
||||||
|
<div className="rounded-lg bg-muted p-3 text-sm space-y-1">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Plan</span>
|
||||||
|
<span className="font-medium">{plan.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Billing</span>
|
||||||
|
<span className="font-medium">{isAnnual ? "Annual" : "Monthly"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Total</span>
|
||||||
|
<span className="font-semibold">{displayPrice(plan)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
disabled={!selectedPlan || !selectedDc || purchase.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!selectedPlan || !selectedDc) return;
|
||||||
|
purchase.mutate({
|
||||||
|
plan: selectedPlan,
|
||||||
|
dataCenterId: Number(selectedDc),
|
||||||
|
isAnnual,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{purchase.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||||
|
Placing order...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Order Server"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShowManagedServers = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const { data: servers, isLoading } = api.managedServer.list.useQuery();
|
||||||
|
|
||||||
|
const syncStatus = api.managedServer.syncStatus.useMutation({
|
||||||
|
onSuccess: () => utils.managedServer.list.invalidate(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteServer = api.managedServer.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Server terminated.");
|
||||||
|
utils.managedServer.list.invalidate();
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||||
|
<div className="rounded-xl bg-background shadow-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
|
<Server className="size-6 text-muted-foreground self-center" />
|
||||||
|
Billing
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage your subscription and servers
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 py-4 border-t">
|
||||||
|
<nav className="flex space-x-2 border-b">
|
||||||
|
{navigationItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = router.pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
||||||
|
isActive
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-base">Managed Servers</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Servers provisioned and managed by Dokploy Cloud
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<OrderServerDialog
|
||||||
|
onSuccess={() => utils.managedServer.list.invalidate()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : servers?.length === 0 ? (
|
||||||
|
<div className="text-center py-12 border rounded-lg border-dashed">
|
||||||
|
<Server className="size-10 mx-auto text-muted-foreground mb-3" />
|
||||||
|
<p className="text-sm font-medium">No managed servers yet</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Order a server and we'll provision and configure it for you
|
||||||
|
automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{servers?.map((s) => {
|
||||||
|
const status =
|
||||||
|
STATUS_MAP[s.status] ?? STATUS_MAP.error!;
|
||||||
|
const isProvisioning = [
|
||||||
|
"pending",
|
||||||
|
"provisioning",
|
||||||
|
"configuring",
|
||||||
|
].includes(s.status);
|
||||||
|
const planLabel = s.plan
|
||||||
|
.split("-")
|
||||||
|
.slice(-2)
|
||||||
|
.join(" ")
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.managedServerId}
|
||||||
|
className="flex items-center justify-between rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Server className="size-5 text-muted-foreground shrink-0" />
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
{planLabel}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant={status?.variant}
|
||||||
|
className="flex items-center gap-1 text-xs h-5"
|
||||||
|
>
|
||||||
|
{status?.icon}
|
||||||
|
{status?.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{s.hostname ?? ""}
|
||||||
|
{s.ipAddress ? ` · ${s.ipAddress}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isProvisioning && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
syncStatus.mutate({
|
||||||
|
managedServerId: s.managedServerId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={syncStatus.isPending}
|
||||||
|
>
|
||||||
|
<Loader2
|
||||||
|
className={cn(
|
||||||
|
"size-4",
|
||||||
|
syncStatus.isPending && "animate-spin",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{s.status === "ready" && s.server && (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/settings/server?serverId=${s.serverId}`}
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-3.5 mr-1.5" />
|
||||||
|
Open
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<DialogAction
|
||||||
|
title="Terminate Server"
|
||||||
|
description="This will permanently destroy the server and all data on it. This action cannot be undone."
|
||||||
|
type="destructive"
|
||||||
|
onClick={() =>
|
||||||
|
deleteServer.mutate({
|
||||||
|
managedServerId: s.managedServerId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ServerIcon } from "lucide-react";
|
import { CopyIcon, ServerIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { ShowDokployActions } from "./servers/actions/show-dokploy-actions";
|
import { ShowDokployActions } from "./servers/actions/show-dokploy-actions";
|
||||||
import { ShowStorageActions } from "./servers/actions/show-storage-actions";
|
import { ShowStorageActions } from "./servers/actions/show-storage-actions";
|
||||||
import { ShowTraefikActions } from "./servers/actions/show-traefik-actions";
|
import { ShowTraefikActions } from "./servers/actions/show-traefik-actions";
|
||||||
@@ -49,8 +51,17 @@ export const WebServer = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center flex-wrap justify-between gap-4">
|
<div className="flex items-center flex-wrap justify-between gap-4">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground flex items-center gap-1.5">
|
||||||
Server IP: {webServerSettings?.serverIp}
|
Server IP: {webServerSettings?.serverIp}
|
||||||
|
{webServerSettings?.serverIp && (
|
||||||
|
<CopyIcon
|
||||||
|
className="size-3.5 cursor-pointer hover:text-foreground transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
copy(webServerSettings.serverIp ?? "");
|
||||||
|
toast.success("Copied to clipboard");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Version: {dokployVersion}
|
Version: {dokployVersion}
|
||||||
|
|||||||
22
apps/dokploy/drizzle/0167_dizzy_solo.sql
Normal file
22
apps/dokploy/drizzle/0167_dizzy_solo.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
CREATE TYPE "public"."managedServerStatus" AS ENUM('pending', 'provisioning', 'configuring', 'ready', 'error', 'terminating', 'terminated');--> statement-breakpoint
|
||||||
|
CREATE TABLE "managed_server" (
|
||||||
|
"managedServerId" text PRIMARY KEY NOT NULL,
|
||||||
|
"organizationId" text NOT NULL,
|
||||||
|
"serverId" text,
|
||||||
|
"plan" text NOT NULL,
|
||||||
|
"status" "managedServerStatus" DEFAULT 'pending' NOT NULL,
|
||||||
|
"hostingerVmId" integer,
|
||||||
|
"hostingerSubscriptionId" text,
|
||||||
|
"dataCenterId" integer NOT NULL,
|
||||||
|
"ipAddress" text,
|
||||||
|
"hostname" text,
|
||||||
|
"stripeSubscriptionId" text,
|
||||||
|
"stripePriceId" text,
|
||||||
|
"rootPassword" text,
|
||||||
|
"errorMessage" text,
|
||||||
|
"createdAt" text NOT NULL,
|
||||||
|
"updatedAt" text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE set null ON UPDATE no action;
|
||||||
8469
apps/dokploy/drizzle/meta/0167_snapshot.json
Normal file
8469
apps/dokploy/drizzle/meta/0167_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1170,6 +1170,13 @@
|
|||||||
"when": 1778303519111,
|
"when": 1778303519111,
|
||||||
"tag": "0166_nosy_slapstick",
|
"tag": "0166_nosy_slapstick",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 167,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778657133470,
|
||||||
|
"tag": "0167_dizzy_solo",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dokploy",
|
"name": "dokploy",
|
||||||
"version": "v0.29.3",
|
"version": "v0.29.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { AddAiAssistant } from "@/components/dashboard/project/add-ai-assistant"
|
|||||||
import { AddApplication } from "@/components/dashboard/project/add-application";
|
import { AddApplication } from "@/components/dashboard/project/add-application";
|
||||||
import { AddCompose } from "@/components/dashboard/project/add-compose";
|
import { AddCompose } from "@/components/dashboard/project/add-compose";
|
||||||
import { AddDatabase } from "@/components/dashboard/project/add-database";
|
import { AddDatabase } from "@/components/dashboard/project/add-database";
|
||||||
|
import { AddImport } from "@/components/dashboard/project/add-import";
|
||||||
import { AddTemplate } from "@/components/dashboard/project/add-template";
|
import { AddTemplate } from "@/components/dashboard/project/add-template";
|
||||||
import { AdvancedEnvironmentSelector } from "@/components/dashboard/project/advanced-environment-selector";
|
import { AdvancedEnvironmentSelector } from "@/components/dashboard/project/advanced-environment-selector";
|
||||||
import { DuplicateProject } from "@/components/dashboard/project/duplicate-project";
|
import { DuplicateProject } from "@/components/dashboard/project/duplicate-project";
|
||||||
@@ -1091,6 +1092,10 @@ const EnvironmentPage = (
|
|||||||
projectName={projectData?.name}
|
projectName={projectData?.name}
|
||||||
environmentId={environmentId}
|
environmentId={environmentId}
|
||||||
/>
|
/>
|
||||||
|
<AddImport
|
||||||
|
projectName={projectData?.name}
|
||||||
|
environmentId={environmentId}
|
||||||
|
/>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ const Service = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: serverIp } = api.settings.getIp.useQuery();
|
||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
|
||||||
@@ -147,8 +148,9 @@ const Service = (
|
|||||||
<Badge
|
<Badge
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (data?.server?.ipAddress) {
|
const ip = data?.server?.ipAddress || serverIp;
|
||||||
copy(data.server.ipAddress);
|
if (ip) {
|
||||||
|
copy(ip);
|
||||||
toast.success("IP Address Copied!");
|
toast.success("IP Address Copied!");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ const Service = (
|
|||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: serverIp } = api.settings.getIp.useQuery();
|
||||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
projectId: data?.environment?.projectId || "",
|
projectId: data?.environment?.projectId || "",
|
||||||
});
|
});
|
||||||
@@ -134,8 +135,9 @@ const Service = (
|
|||||||
<Badge
|
<Badge
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (data?.server?.ipAddress) {
|
const ip = data?.server?.ipAddress || serverIp;
|
||||||
copy(data.server.ipAddress);
|
if (ip) {
|
||||||
|
copy(ip);
|
||||||
toast.success("IP Address Copied!");
|
toast.success("IP Address Copied!");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import copy from "copy-to-clipboard";
|
||||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||||
import { HelpCircle, ServerOff } from "lucide-react";
|
import { HelpCircle, ServerOff } from "lucide-react";
|
||||||
@@ -10,6 +11,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { type ReactElement, useState } from "react";
|
import { type ReactElement, useState } from "react";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
||||||
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
||||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||||
@@ -61,6 +63,7 @@ const Libsql = (
|
|||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: serverIp } = api.settings.getIp.useQuery();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-10">
|
<div className="pb-10">
|
||||||
@@ -99,6 +102,14 @@ const Libsql = (
|
|||||||
<div className="flex flex-col h-fit w-fit gap-2">
|
<div className="flex flex-col h-fit w-fit gap-2">
|
||||||
<div className="flex flex-row h-fit w-fit gap-2">
|
<div className="flex flex-row h-fit w-fit gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const ip = data?.server?.ipAddress || serverIp;
|
||||||
|
if (ip) {
|
||||||
|
copy(ip);
|
||||||
|
toast.success("IP Address Copied!");
|
||||||
|
}
|
||||||
|
}}
|
||||||
variant={
|
variant={
|
||||||
!data?.serverId
|
!data?.serverId
|
||||||
? "default"
|
? "default"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import copy from "copy-to-clipboard";
|
||||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||||
import { HelpCircle, ServerOff } from "lucide-react";
|
import { HelpCircle, ServerOff } from "lucide-react";
|
||||||
@@ -10,6 +11,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { type ReactElement, useState } from "react";
|
import { type ReactElement, useState } from "react";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
||||||
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
||||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||||
@@ -63,6 +65,7 @@ const Mariadb = (
|
|||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: serverIp } = api.settings.getIp.useQuery();
|
||||||
|
|
||||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
projectId: data?.environment?.projectId || "",
|
projectId: data?.environment?.projectId || "",
|
||||||
@@ -111,6 +114,14 @@ const Mariadb = (
|
|||||||
<div className="flex flex-col h-fit w-fit gap-2">
|
<div className="flex flex-col h-fit w-fit gap-2">
|
||||||
<div className="flex flex-row h-fit w-fit gap-2">
|
<div className="flex flex-row h-fit w-fit gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const ip = data?.server?.ipAddress || serverIp;
|
||||||
|
if (ip) {
|
||||||
|
copy(ip);
|
||||||
|
toast.success("IP Address Copied!");
|
||||||
|
}
|
||||||
|
}}
|
||||||
variant={
|
variant={
|
||||||
!data?.serverId
|
!data?.serverId
|
||||||
? "default"
|
? "default"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import copy from "copy-to-clipboard";
|
||||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||||
import { HelpCircle, ServerOff } from "lucide-react";
|
import { HelpCircle, ServerOff } from "lucide-react";
|
||||||
@@ -10,6 +11,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { type ReactElement, useState } from "react";
|
import { type ReactElement, useState } from "react";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
||||||
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
||||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||||
@@ -63,6 +65,7 @@ const Mongo = (
|
|||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: serverIp } = api.settings.getIp.useQuery();
|
||||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
projectId: data?.environment?.projectId || "",
|
projectId: data?.environment?.projectId || "",
|
||||||
});
|
});
|
||||||
@@ -110,6 +113,14 @@ const Mongo = (
|
|||||||
<div className="flex flex-col h-fit w-fit gap-2">
|
<div className="flex flex-col h-fit w-fit gap-2">
|
||||||
<div className="flex flex-row h-fit w-fit gap-2">
|
<div className="flex flex-row h-fit w-fit gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const ip = data?.server?.ipAddress || serverIp;
|
||||||
|
if (ip) {
|
||||||
|
copy(ip);
|
||||||
|
toast.success("IP Address Copied!");
|
||||||
|
}
|
||||||
|
}}
|
||||||
variant={
|
variant={
|
||||||
!data?.serverId
|
!data?.serverId
|
||||||
? "default"
|
? "default"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
import { HelpCircle, ServerOff } from "lucide-react";
|
import { HelpCircle, ServerOff } from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
GetServerSidePropsContext,
|
GetServerSidePropsContext,
|
||||||
@@ -10,6 +11,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { type ReactElement, useState } from "react";
|
import { type ReactElement, useState } from "react";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
||||||
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
||||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||||
@@ -62,6 +64,7 @@ const MySql = (
|
|||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: serverIp } = api.settings.getIp.useQuery();
|
||||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
projectId: data?.environment?.projectId || "",
|
projectId: data?.environment?.projectId || "",
|
||||||
});
|
});
|
||||||
@@ -110,6 +113,14 @@ const MySql = (
|
|||||||
<div className="flex flex-col h-fit w-fit gap-2">
|
<div className="flex flex-col h-fit w-fit gap-2">
|
||||||
<div className="flex flex-row h-fit w-fit gap-2">
|
<div className="flex flex-row h-fit w-fit gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const ip = data?.server?.ipAddress || serverIp;
|
||||||
|
if (ip) {
|
||||||
|
copy(ip);
|
||||||
|
toast.success("IP Address Copied!");
|
||||||
|
}
|
||||||
|
}}
|
||||||
variant={
|
variant={
|
||||||
!data?.serverId
|
!data?.serverId
|
||||||
? "default"
|
? "default"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import copy from "copy-to-clipboard";
|
||||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||||
import { HelpCircle, ServerOff } from "lucide-react";
|
import { HelpCircle, ServerOff } from "lucide-react";
|
||||||
@@ -10,6 +11,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { type ReactElement, useState } from "react";
|
import { type ReactElement, useState } from "react";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
||||||
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
||||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||||
@@ -62,6 +64,7 @@ const Postgresql = (
|
|||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: serverIp } = api.settings.getIp.useQuery();
|
||||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
projectId: data?.environment?.projectId || "",
|
projectId: data?.environment?.projectId || "",
|
||||||
});
|
});
|
||||||
@@ -109,6 +112,14 @@ const Postgresql = (
|
|||||||
<div className="flex flex-col h-fit w-fit gap-2">
|
<div className="flex flex-col h-fit w-fit gap-2">
|
||||||
<div className="flex flex-row h-fit w-fit gap-2">
|
<div className="flex flex-row h-fit w-fit gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const ip = data?.server?.ipAddress || serverIp;
|
||||||
|
if (ip) {
|
||||||
|
copy(ip);
|
||||||
|
toast.success("IP Address Copied!");
|
||||||
|
}
|
||||||
|
}}
|
||||||
variant={
|
variant={
|
||||||
!data?.serverId
|
!data?.serverId
|
||||||
? "default"
|
? "default"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import copy from "copy-to-clipboard";
|
||||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||||
import { HelpCircle, ServerOff } from "lucide-react";
|
import { HelpCircle, ServerOff } from "lucide-react";
|
||||||
@@ -10,6 +11,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { type ReactElement, useState } from "react";
|
import { type ReactElement, useState } from "react";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
||||||
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
||||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||||
@@ -62,6 +64,7 @@ const Redis = (
|
|||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: serverIp } = api.settings.getIp.useQuery();
|
||||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
projectId: data?.environment?.projectId || "",
|
projectId: data?.environment?.projectId || "",
|
||||||
});
|
});
|
||||||
@@ -109,6 +112,14 @@ const Redis = (
|
|||||||
<div className="flex flex-col h-fit w-fit gap-2">
|
<div className="flex flex-col h-fit w-fit gap-2">
|
||||||
<div className="flex flex-row h-fit w-fit gap-2">
|
<div className="flex flex-row h-fit w-fit gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const ip = data?.server?.ipAddress || serverIp;
|
||||||
|
if (ip) {
|
||||||
|
copy(ip);
|
||||||
|
toast.success("IP Address Copied!");
|
||||||
|
}
|
||||||
|
}}
|
||||||
variant={
|
variant={
|
||||||
!data?.serverId
|
!data?.serverId
|
||||||
? "default"
|
? "default"
|
||||||
|
|||||||
39
apps/dokploy/pages/dashboard/settings/managed-servers.tsx
Normal file
39
apps/dokploy/pages/dashboard/settings/managed-servers.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||||
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
|
import type { GetServerSidePropsContext } from "next";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { ShowManagedServers } from "@/components/dashboard/settings/billing/show-managed-servers";
|
||||||
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
|
|
||||||
|
const Page = () => {
|
||||||
|
return <ShowManagedServers />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
||||||
|
|
||||||
|
Page.getLayout = (page: ReactElement) => {
|
||||||
|
return <DashboardLayout metaName="Managed Servers">{page}</DashboardLayout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getServerSideProps(
|
||||||
|
ctx: GetServerSidePropsContext,
|
||||||
|
) {
|
||||||
|
if (!IS_CLOUD) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: false,
|
||||||
|
destination: "/dashboard/home",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { user } = await validateRequest(ctx.req);
|
||||||
|
if (!user || user.role !== "owner") {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: false,
|
||||||
|
destination: "/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { props: {} };
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ async function main() {
|
|||||||
|
|
||||||
if (records.length === 0) {
|
if (records.length === 0) {
|
||||||
console.log("✅ No 2FA records found, nothing to migrate.");
|
console.log("✅ No 2FA records found, nothing to migrate.");
|
||||||
return;
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`📦 Found ${records.length} 2FA record(s) to migrate.`);
|
console.log(`📦 Found ${records.length} 2FA record(s) to migrate.`);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { projectRouter } from "./routers/project";
|
|||||||
import { auditLogRouter } from "./routers/proprietary/audit-log";
|
import { auditLogRouter } from "./routers/proprietary/audit-log";
|
||||||
import { customRoleRouter } from "./routers/proprietary/custom-role";
|
import { customRoleRouter } from "./routers/proprietary/custom-role";
|
||||||
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||||
|
import { managedServerRouter } from "./routers/proprietary/managed-server";
|
||||||
import { ssoRouter } from "./routers/proprietary/sso";
|
import { ssoRouter } from "./routers/proprietary/sso";
|
||||||
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
|
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
|
||||||
import { redirectsRouter } from "./routers/redirects";
|
import { redirectsRouter } from "./routers/redirects";
|
||||||
@@ -102,6 +103,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
environment: environmentRouter,
|
environment: environmentRouter,
|
||||||
tag: tagRouter,
|
tag: tagRouter,
|
||||||
patch: patchRouter,
|
patch: patchRouter,
|
||||||
|
managedServer: managedServerRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -862,6 +862,76 @@ export const composeRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
previewTemplate: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
base64: z.string(),
|
||||||
|
appName: z.string(),
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
if (input.serverId) {
|
||||||
|
const accessibleIds = await getAccessibleServerIds(ctx.session);
|
||||||
|
if (!accessibleIds.has(input.serverId)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this server",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedData = Buffer.from(input.base64, "base64").toString(
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
let serverIp = "127.0.0.1";
|
||||||
|
|
||||||
|
if (input.serverId) {
|
||||||
|
const server = await findServerById(input.serverId);
|
||||||
|
serverIp = server.ipAddress;
|
||||||
|
} else if (process.env.NODE_ENV !== "development") {
|
||||||
|
const settings = await getWebServerSettings();
|
||||||
|
serverIp = settings?.serverIp || "127.0.0.1";
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateData = JSON.parse(decodedData);
|
||||||
|
const config = parse(templateData.config) as CompleteTemplate;
|
||||||
|
|
||||||
|
if (!templateData.compose || !config) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message:
|
||||||
|
"Invalid template format. Must contain compose and config fields",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const configModified = {
|
||||||
|
...config,
|
||||||
|
variables: {
|
||||||
|
APP_NAME: input.appName,
|
||||||
|
...config.variables,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const processedTemplate = processTemplate(configModified, {
|
||||||
|
serverIp,
|
||||||
|
projectName: input.appName,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
compose: templateData.compose,
|
||||||
|
template: processedTemplate,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Error processing template: ${error instanceof Error ? error.message : error}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
import: protectedProcedure
|
import: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -151,6 +151,14 @@ export const deploymentRouter = createTRPCRouter({
|
|||||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||||
deployment: ["cancel"],
|
deployment: ["cancel"],
|
||||||
});
|
});
|
||||||
|
} else if (deployment.schedule?.serverId) {
|
||||||
|
const targetServer = await findServerById(deployment.schedule.serverId);
|
||||||
|
if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You don't have access to this deployment.",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!deployment.pid) {
|
if (!deployment.pid) {
|
||||||
@@ -188,6 +196,14 @@ export const deploymentRouter = createTRPCRouter({
|
|||||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||||
deployment: ["cancel"],
|
deployment: ["cancel"],
|
||||||
});
|
});
|
||||||
|
} else if (deployment.schedule?.serverId) {
|
||||||
|
const targetServer = await findServerById(deployment.schedule.serverId);
|
||||||
|
if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You don't have access to this deployment.",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const result = await removeDeployment(input.deploymentId);
|
const result = await removeDeployment(input.deploymentId);
|
||||||
await audit(ctx, {
|
await audit(ctx, {
|
||||||
@@ -197,4 +213,47 @@ export const deploymentRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
readLogs: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
deploymentId: z.string().min(1),
|
||||||
|
tail: z.number().int().min(1).max(10000).default(100),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const deployment = await findDeploymentById(input.deploymentId);
|
||||||
|
const serviceId = deployment.applicationId || deployment.composeId;
|
||||||
|
if (serviceId) {
|
||||||
|
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||||
|
deployment: ["read"],
|
||||||
|
});
|
||||||
|
} else if (deployment.schedule?.serverId) {
|
||||||
|
const targetServer = await findServerById(deployment.schedule.serverId);
|
||||||
|
if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You don't have access to this deployment.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deployment.logPath) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = `tail -n ${input.tail} "${deployment.logPath}" 2>/dev/null || echo ""`;
|
||||||
|
const serverId = deployment.serverId || deployment.schedule?.serverId;
|
||||||
|
if (serverId) {
|
||||||
|
const { stdout } = await execAsyncRemote(serverId, command);
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stdout } = await execAsync(command);
|
||||||
|
return stdout;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -295,6 +295,14 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Owner role is non-delegable — no one can invite as owner
|
||||||
|
if (input.role === "owner") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Cannot invite a user with the owner role",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// If assigning a custom role, verify it exists
|
// If assigning a custom role, verify it exists
|
||||||
if (!["owner", "admin", "member"].includes(input.role)) {
|
if (!["owner", "admin", "member"].includes(input.role)) {
|
||||||
const customRole = await db.query.organizationRole.findFirst({
|
const customRole = await db.query.organizationRole.findFirst({
|
||||||
|
|||||||
247
apps/dokploy/server/api/routers/proprietary/managed-server.ts
Normal file
247
apps/dokploy/server/api/routers/proprietary/managed-server.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import {
|
||||||
|
createServer,
|
||||||
|
IS_CLOUD,
|
||||||
|
serverSetup,
|
||||||
|
} from "@dokploy/server";
|
||||||
|
import {
|
||||||
|
apiCreateManagedServer,
|
||||||
|
apiDeleteManagedServer,
|
||||||
|
apiFindOneManagedServer,
|
||||||
|
} from "@dokploy/server/db/schema/managed-server";
|
||||||
|
import {
|
||||||
|
createManagedServer,
|
||||||
|
deleteManagedServer,
|
||||||
|
findManagedServerById,
|
||||||
|
findManagedServersByOrg,
|
||||||
|
updateManagedServer,
|
||||||
|
} from "@dokploy/server/services/managed-server";
|
||||||
|
import {
|
||||||
|
getHostingerDataCenters,
|
||||||
|
getHostingerVm,
|
||||||
|
getManagedServerPlans,
|
||||||
|
purchaseHostingerVps,
|
||||||
|
stopHostingerVm,
|
||||||
|
UBUNTU_22_TEMPLATE_ID,
|
||||||
|
} from "@dokploy/server/utils/hostinger";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { adminProcedure, createTRPCRouter } from "../../trpc";
|
||||||
|
|
||||||
|
export const managedServerRouter = createTRPCRouter({
|
||||||
|
getPlans: adminProcedure.query(async () => {
|
||||||
|
if (!IS_CLOUD) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Managed servers are only available in Dokploy Cloud",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return getManagedServerPlans();
|
||||||
|
}),
|
||||||
|
|
||||||
|
getDataCenters: adminProcedure.query(async () => {
|
||||||
|
if (!IS_CLOUD) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Managed servers are only available in Dokploy Cloud",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return getHostingerDataCenters();
|
||||||
|
}),
|
||||||
|
|
||||||
|
list: adminProcedure.query(async ({ ctx }) => {
|
||||||
|
if (!IS_CLOUD) return [];
|
||||||
|
return findManagedServersByOrg(ctx.session.activeOrganizationId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
one: adminProcedure
|
||||||
|
.input(apiFindOneManagedServer)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
if (!IS_CLOUD) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
|
||||||
|
}
|
||||||
|
const record = await findManagedServerById(input.managedServerId);
|
||||||
|
if (record.organizationId !== ctx.session.activeOrganizationId) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}),
|
||||||
|
|
||||||
|
purchase: adminProcedure
|
||||||
|
.input(apiCreateManagedServer)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
if (!IS_CLOUD) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Managed servers are only available in Dokploy Cloud",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const plans = await getManagedServerPlans();
|
||||||
|
const plan = plans.find((p) => p.id === input.plan);
|
||||||
|
if (!plan) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid plan" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname =
|
||||||
|
`dokploy-${ctx.session.activeOrganizationId.slice(0, 8)}-${nanoid(6)}`.toLowerCase();
|
||||||
|
|
||||||
|
const managedRecord = await createManagedServer({
|
||||||
|
organizationId: ctx.session.activeOrganizationId,
|
||||||
|
plan: input.plan,
|
||||||
|
dataCenterId: input.dataCenterId,
|
||||||
|
status: "provisioning",
|
||||||
|
});
|
||||||
|
|
||||||
|
const hostingerItemId = input.isAnnual
|
||||||
|
? plan.hostingerItemIdAnnual
|
||||||
|
: plan.hostingerItemIdMonthly;
|
||||||
|
|
||||||
|
provisionManagedServer(
|
||||||
|
managedRecord.managedServerId,
|
||||||
|
hostingerItemId,
|
||||||
|
input.dataCenterId,
|
||||||
|
hostname,
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
).catch(async (err) => {
|
||||||
|
await updateManagedServer(managedRecord.managedServerId, {
|
||||||
|
status: "error",
|
||||||
|
errorMessage: err?.message ?? "Unknown error during provisioning",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return managedRecord;
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: adminProcedure
|
||||||
|
.input(apiDeleteManagedServer)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
if (!IS_CLOUD) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
|
||||||
|
}
|
||||||
|
const record = await findManagedServerById(input.managedServerId);
|
||||||
|
if (record.organizationId !== ctx.session.activeOrganizationId) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateManagedServer(input.managedServerId, {
|
||||||
|
status: "terminating",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (record.hostingerVmId) {
|
||||||
|
try {
|
||||||
|
await stopHostingerVm(record.hostingerVmId);
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteManagedServer(input.managedServerId);
|
||||||
|
return { ok: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
syncStatus: adminProcedure
|
||||||
|
.input(apiFindOneManagedServer)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
if (!IS_CLOUD) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
|
||||||
|
}
|
||||||
|
const record = await findManagedServerById(input.managedServerId);
|
||||||
|
if (record.organizationId !== ctx.session.activeOrganizationId) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
if (!record.hostingerVmId) return record;
|
||||||
|
|
||||||
|
const vm = await getHostingerVm(record.hostingerVmId);
|
||||||
|
const ipAddress = vm.ipv4?.[0]?.address ?? record.ipAddress;
|
||||||
|
|
||||||
|
await updateManagedServer(input.managedServerId, {
|
||||||
|
ipAddress: ipAddress ?? undefined,
|
||||||
|
hostname: vm.hostname ?? undefined,
|
||||||
|
status:
|
||||||
|
vm.state === "running"
|
||||||
|
? record.serverId
|
||||||
|
? "ready"
|
||||||
|
: "configuring"
|
||||||
|
: record.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
return findManagedServerById(input.managedServerId);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function provisionManagedServer(
|
||||||
|
managedServerId: string,
|
||||||
|
hostingerItemId: string,
|
||||||
|
dataCenterId: number,
|
||||||
|
hostname: string,
|
||||||
|
organizationId: string,
|
||||||
|
) {
|
||||||
|
const result = await purchaseHostingerVps({
|
||||||
|
item_id: hostingerItemId,
|
||||||
|
payment_method_id: 0,
|
||||||
|
setup: {
|
||||||
|
template_id: UBUNTU_22_TEMPLATE_ID,
|
||||||
|
data_center_id: dataCenterId,
|
||||||
|
hostname,
|
||||||
|
enable_backups: false,
|
||||||
|
},
|
||||||
|
coupons: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const vm = result.virtual_machine;
|
||||||
|
|
||||||
|
await updateManagedServer(managedServerId, {
|
||||||
|
hostingerVmId: vm.id,
|
||||||
|
hostingerSubscriptionId: vm.subscription_id ?? undefined,
|
||||||
|
ipAddress: vm.ipv4?.[0]?.address ?? undefined,
|
||||||
|
hostname: vm.hostname ?? undefined,
|
||||||
|
status: "configuring",
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForVmRunning(vm.id!, managedServerId);
|
||||||
|
|
||||||
|
const finalVm = await getHostingerVm(vm.id!);
|
||||||
|
const finalIp = finalVm.ipv4?.[0]?.address;
|
||||||
|
|
||||||
|
if (!finalIp) {
|
||||||
|
throw new Error("VM is running but has no IPv4 address");
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverRecord = await createServer(
|
||||||
|
{
|
||||||
|
name: `Managed • ${hostname}`,
|
||||||
|
description: "Managed server provisioned by Dokploy Cloud",
|
||||||
|
ipAddress: finalIp,
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
serverType: "deploy",
|
||||||
|
},
|
||||||
|
organizationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
await updateManagedServer(managedServerId, {
|
||||||
|
serverId: serverRecord.serverId,
|
||||||
|
ipAddress: finalIp,
|
||||||
|
});
|
||||||
|
|
||||||
|
await serverSetup(serverRecord.serverId);
|
||||||
|
|
||||||
|
await updateManagedServer(managedServerId, { status: "ready" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForVmRunning(
|
||||||
|
vmId: number,
|
||||||
|
_managedServerId: string,
|
||||||
|
maxAttempts = 30,
|
||||||
|
intervalMs = 10_000,
|
||||||
|
) {
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, intervalMs));
|
||||||
|
const vm = await getHostingerVm(vmId);
|
||||||
|
if (vm.state === "running") return;
|
||||||
|
if (vm.state === "error") {
|
||||||
|
throw new Error("VM entered error state");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Timed out waiting for VM to become running");
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
apiUpdateUser,
|
apiUpdateUser,
|
||||||
invitation,
|
invitation,
|
||||||
member,
|
member,
|
||||||
|
session,
|
||||||
user,
|
user,
|
||||||
} from "@dokploy/server/db/schema";
|
} from "@dokploy/server/db/schema";
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +33,7 @@ import {
|
|||||||
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
|
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import * as bcrypt from "bcrypt";
|
import * as bcrypt from "bcrypt";
|
||||||
import { and, asc, eq, gt } from "drizzle-orm";
|
import { and, asc, eq, gt, ne } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { audit } from "@/server/api/utils/audit";
|
import { audit } from "@/server/api/utils/audit";
|
||||||
import {
|
import {
|
||||||
@@ -229,6 +230,15 @@ export const userRouter = createTRPCRouter({
|
|||||||
password: bcrypt.hashSync(input.password, 10),
|
password: bcrypt.hashSync(input.password, 10),
|
||||||
})
|
})
|
||||||
.where(eq(account.userId, ctx.user.id));
|
.where(eq(account.userId, ctx.user.id));
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(session)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(session.userId, ctx.user.id),
|
||||||
|
ne(session.id, ctx.session.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -594,6 +604,13 @@ export const userRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.role === "owner") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Cannot create a user with the owner role",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return await createOrganizationUserWithCredentials({
|
return await createOrganizationUserWithCredentials({
|
||||||
organizationId: ctx.session.activeOrganizationId,
|
organizationId: ctx.session.activeOrganizationId,
|
||||||
email: input.email,
|
email: input.email,
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
"drizzle-dbml-generator": "0.10.0",
|
"drizzle-dbml-generator": "0.10.0",
|
||||||
"drizzle-orm": "0.45.1",
|
"drizzle-orm": "0.45.1",
|
||||||
"drizzle-zod": "0.5.1",
|
"drizzle-zod": "0.5.1",
|
||||||
|
"hostinger-api-sdk": "^0.0.17",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"micromatch": "4.0.8",
|
"micromatch": "4.0.8",
|
||||||
"nanoid": "3.3.11",
|
"nanoid": "3.3.11",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export * from "./gitea";
|
|||||||
export * from "./github";
|
export * from "./github";
|
||||||
export * from "./gitlab";
|
export * from "./gitlab";
|
||||||
export * from "./libsql";
|
export * from "./libsql";
|
||||||
|
export * from "./managed-server";
|
||||||
export * from "./mariadb";
|
export * from "./mariadb";
|
||||||
export * from "./mongo";
|
export * from "./mongo";
|
||||||
export * from "./mount";
|
export * from "./mount";
|
||||||
|
|||||||
72
packages/server/src/db/schema/managed-server.ts
Normal file
72
packages/server/src/db/schema/managed-server.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
import { integer, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { organization } from "./account";
|
||||||
|
import { server } from "./server";
|
||||||
|
|
||||||
|
export const managedServerStatus = pgEnum("managedServerStatus", [
|
||||||
|
"pending",
|
||||||
|
"provisioning",
|
||||||
|
"configuring",
|
||||||
|
"ready",
|
||||||
|
"error",
|
||||||
|
"terminating",
|
||||||
|
"terminated",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const managedServer = pgTable("managed_server", {
|
||||||
|
managedServerId: text("managedServerId")
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
organizationId: text("organizationId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => organization.id, { onDelete: "cascade" }),
|
||||||
|
serverId: text("serverId").references(() => server.serverId, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
/** Hostinger catalog item id, e.g. "hostingercom-vps-kvm2" */
|
||||||
|
plan: text("plan").notNull(),
|
||||||
|
status: managedServerStatus("status").notNull().default("pending"),
|
||||||
|
hostingerVmId: integer("hostingerVmId"),
|
||||||
|
hostingerSubscriptionId: text("hostingerSubscriptionId"),
|
||||||
|
dataCenterId: integer("dataCenterId").notNull(),
|
||||||
|
ipAddress: text("ipAddress"),
|
||||||
|
hostname: text("hostname"),
|
||||||
|
stripeSubscriptionId: text("stripeSubscriptionId"),
|
||||||
|
stripePriceId: text("stripePriceId"),
|
||||||
|
rootPassword: text("rootPassword"),
|
||||||
|
errorMessage: text("errorMessage"),
|
||||||
|
createdAt: text("createdAt")
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date().toISOString()),
|
||||||
|
updatedAt: text("updatedAt")
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date().toISOString()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const managedServerRelations = relations(managedServer, ({ one }) => ({
|
||||||
|
organization: one(organization, {
|
||||||
|
fields: [managedServer.organizationId],
|
||||||
|
references: [organization.id],
|
||||||
|
}),
|
||||||
|
server: one(server, {
|
||||||
|
fields: [managedServer.serverId],
|
||||||
|
references: [server.serverId],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const apiCreateManagedServer = z.object({
|
||||||
|
plan: z.string().min(1),
|
||||||
|
dataCenterId: z.number().int().positive(),
|
||||||
|
isAnnual: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiFindOneManagedServer = z.object({
|
||||||
|
managedServerId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiDeleteManagedServer = z.object({
|
||||||
|
managedServerId: z.string().min(1),
|
||||||
|
});
|
||||||
@@ -44,6 +44,13 @@ export const registryRelations = relations(registry, ({ many }) => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Image references require a lowercase namespace (e.g. Docker Hub username).
|
||||||
|
const registryUsernameSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.transform((s) => s.toLowerCase());
|
||||||
|
|
||||||
// Registry URLs must be hostname[:port] only — no shell metacharacters
|
// Registry URLs must be hostname[:port] only — no shell metacharacters
|
||||||
// Empty string is allowed (means default/Docker Hub registry)
|
// Empty string is allowed (means default/Docker Hub registry)
|
||||||
const registryUrlSchema = z
|
const registryUrlSchema = z
|
||||||
@@ -57,7 +64,7 @@ const registryUrlSchema = z
|
|||||||
|
|
||||||
const createSchema = createInsertSchema(registry, {
|
const createSchema = createInsertSchema(registry, {
|
||||||
registryName: z.string().min(1),
|
registryName: z.string().min(1),
|
||||||
username: z.string().min(1),
|
username: registryUsernameSchema,
|
||||||
password: z.string().min(1),
|
password: z.string().min(1),
|
||||||
registryUrl: registryUrlSchema,
|
registryUrl: registryUrlSchema,
|
||||||
organizationId: z.string().min(1),
|
organizationId: z.string().min(1),
|
||||||
@@ -70,7 +77,7 @@ export const apiCreateRegistry = createSchema
|
|||||||
.pick({})
|
.pick({})
|
||||||
.extend({
|
.extend({
|
||||||
registryName: z.string().min(1),
|
registryName: z.string().min(1),
|
||||||
username: z.string().min(1),
|
username: registryUsernameSchema,
|
||||||
password: z.string().min(1),
|
password: z.string().min(1),
|
||||||
registryUrl: registryUrlSchema,
|
registryUrl: registryUrlSchema,
|
||||||
registryType: z.enum(["cloud"]),
|
registryType: z.enum(["cloud"]),
|
||||||
@@ -83,7 +90,7 @@ export const apiCreateRegistry = createSchema
|
|||||||
|
|
||||||
export const apiTestRegistry = createSchema.pick({}).extend({
|
export const apiTestRegistry = createSchema.pick({}).extend({
|
||||||
registryName: z.string().optional(),
|
registryName: z.string().optional(),
|
||||||
username: z.string().min(1),
|
username: registryUsernameSchema,
|
||||||
password: z.string().min(1),
|
password: z.string().min(1),
|
||||||
registryUrl: registryUrlSchema,
|
registryUrl: registryUrlSchema,
|
||||||
registryType: z.enum(["cloud"]),
|
registryType: z.enum(["cloud"]),
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export * from "./services/registry";
|
|||||||
export * from "./services/rollbacks";
|
export * from "./services/rollbacks";
|
||||||
export * from "./services/schedule";
|
export * from "./services/schedule";
|
||||||
export * from "./services/security";
|
export * from "./services/security";
|
||||||
|
export * from "./services/managed-server";
|
||||||
export * from "./services/server";
|
export * from "./services/server";
|
||||||
export * from "./services/settings";
|
export * from "./services/settings";
|
||||||
export * from "./services/ssh-key";
|
export * from "./services/ssh-key";
|
||||||
|
|||||||
54
packages/server/src/services/managed-server.ts
Normal file
54
packages/server/src/services/managed-server.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { db } from "@dokploy/server/db";
|
||||||
|
import { managedServer } from "@dokploy/server/db/schema/managed-server";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
export type ManagedServer = typeof managedServer.$inferSelect;
|
||||||
|
|
||||||
|
export const createManagedServer = async (
|
||||||
|
input: typeof managedServer.$inferInsert,
|
||||||
|
) => {
|
||||||
|
const record = await db
|
||||||
|
.insert(managedServer)
|
||||||
|
.values(input)
|
||||||
|
.returning()
|
||||||
|
.then((r) => r[0]);
|
||||||
|
if (!record) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||||
|
return record;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findManagedServerById = async (managedServerId: string) => {
|
||||||
|
const record = await db.query.managedServer.findFirst({
|
||||||
|
where: eq(managedServer.managedServerId, managedServerId),
|
||||||
|
with: { server: true },
|
||||||
|
});
|
||||||
|
if (!record)
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Managed server not found" });
|
||||||
|
return record;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findManagedServersByOrg = async (organizationId: string) => {
|
||||||
|
return db.query.managedServer.findMany({
|
||||||
|
where: eq(managedServer.organizationId, organizationId),
|
||||||
|
with: { server: true },
|
||||||
|
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateManagedServer = async (
|
||||||
|
managedServerId: string,
|
||||||
|
data: Partial<typeof managedServer.$inferInsert>,
|
||||||
|
) => {
|
||||||
|
return db
|
||||||
|
.update(managedServer)
|
||||||
|
.set({ ...data, updatedAt: new Date().toISOString() })
|
||||||
|
.where(eq(managedServer.managedServerId, managedServerId))
|
||||||
|
.returning()
|
||||||
|
.then((r) => r[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteManagedServer = async (managedServerId: string) => {
|
||||||
|
return db
|
||||||
|
.delete(managedServer)
|
||||||
|
.where(eq(managedServer.managedServerId, managedServerId));
|
||||||
|
};
|
||||||
@@ -101,8 +101,8 @@ export const getRegistryTag = (registry: Registry, imageName: string) => {
|
|||||||
// Extract the repository name (last part after '/')
|
// Extract the repository name (last part after '/')
|
||||||
const repositoryName = extractRepositoryName(imageName);
|
const repositoryName = extractRepositoryName(imageName);
|
||||||
|
|
||||||
// Build the final tag using registry's username/prefix
|
// Build the final tag using registry's username/prefix (must be lowercase for valid image refs)
|
||||||
const targetPrefix = imagePrefix || username;
|
const targetPrefix = (imagePrefix || username).toLowerCase();
|
||||||
const finalRegistry = registryUrl || "";
|
const finalRegistry = registryUrl || "";
|
||||||
|
|
||||||
return finalRegistry
|
return finalRegistry
|
||||||
|
|||||||
164
packages/server/src/utils/hostinger.ts
Normal file
164
packages/server/src/utils/hostinger.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import {
|
||||||
|
BillingCatalogApi,
|
||||||
|
Configuration,
|
||||||
|
VPSDataCentersApi,
|
||||||
|
VPSVirtualMachineApi,
|
||||||
|
} from "hostinger-api-sdk";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
BillingV1CatalogCatalogItemResource as HostingerCatalogItem,
|
||||||
|
VPSV1DataCenterDataCenterResource as HostingerDataCenter,
|
||||||
|
VPSV1VirtualMachinePurchaseRequest as HostingerPurchaseRequest,
|
||||||
|
VPSV1VirtualMachineVirtualMachineResource as HostingerVM,
|
||||||
|
} from "hostinger-api-sdk";
|
||||||
|
|
||||||
|
// Correct base URL — api.hostinger.com returns 530, developers.hostinger.com is the real gateway
|
||||||
|
const HOSTINGER_BASE_PATH = "https://developers.hostinger.com";
|
||||||
|
|
||||||
|
function getConfig() {
|
||||||
|
const apiKey = process.env.HOSTINGER_API_KEY;
|
||||||
|
if (!apiKey) throw new Error("HOSTINGER_API_KEY is not set");
|
||||||
|
return new Configuration({
|
||||||
|
basePath: HOSTINGER_BASE_PATH,
|
||||||
|
accessToken: apiKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVmApi() {
|
||||||
|
return new VPSVirtualMachineApi(getConfig());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHostingerDataCenters() {
|
||||||
|
try {
|
||||||
|
const api = new VPSDataCentersApi(getConfig());
|
||||||
|
const res = await api.getDataCenterListV1();
|
||||||
|
return res.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHostingerVpsCatalog() {
|
||||||
|
const api = new BillingCatalogApi(getConfig());
|
||||||
|
const res = await api.getCatalogItemListV1("VPS");
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function purchaseHostingerVps(
|
||||||
|
body: import("hostinger-api-sdk").VPSV1VirtualMachinePurchaseRequest,
|
||||||
|
) {
|
||||||
|
const api = getVmApi();
|
||||||
|
const res = await api.purchaseNewVirtualMachineV1(body);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHostingerVm(vmId: number) {
|
||||||
|
const api = getVmApi();
|
||||||
|
const res = await api.getVirtualMachineDetailsV1(vmId);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopHostingerVm(vmId: number) {
|
||||||
|
const api = getVmApi();
|
||||||
|
await api.stopVirtualMachineV1(vmId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ubuntu 22.04 LTS template ID on Hostinger */
|
||||||
|
export const UBUNTU_22_TEMPLATE_ID = 1009;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markup multiplier applied to Hostinger's catalog price to get Dokploy's user price.
|
||||||
|
* Hostinger KVM2 = ~$24.49/mo → Dokploy charges $45/mo (~84% markup).
|
||||||
|
*/
|
||||||
|
const MARKUP = 1.84;
|
||||||
|
|
||||||
|
export interface ManagedServerPlan {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hostingerItemIdMonthly: string;
|
||||||
|
hostingerItemIdAnnual: string;
|
||||||
|
cpus: number;
|
||||||
|
memoryMb: number;
|
||||||
|
diskMb: number;
|
||||||
|
bandwidthMb: number;
|
||||||
|
/** Price in cents Hostinger charges us monthly */
|
||||||
|
hostingerPriceCentsMonthly: number;
|
||||||
|
/** Price in cents we charge the user monthly */
|
||||||
|
dokployPriceCentsMonthly: number;
|
||||||
|
/** Price in cents we charge the user annually */
|
||||||
|
dokployPriceCentsAnnual: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** KVM plan IDs offered through Dokploy (excludes Game Panel plans) */
|
||||||
|
const OFFERED_PLAN_IDS = [
|
||||||
|
"hostingercom-vps-kvm1",
|
||||||
|
"hostingercom-vps-kvm2",
|
||||||
|
"hostingercom-vps-kvm4",
|
||||||
|
"hostingercom-vps-kvm8",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches live VPS plans from Hostinger catalog and applies Dokploy markup.
|
||||||
|
* Only returns standard KVM plans (not Game Panel variants).
|
||||||
|
*/
|
||||||
|
export async function getManagedServerPlans(): Promise<ManagedServerPlan[]> {
|
||||||
|
const catalog = await getHostingerVpsCatalog();
|
||||||
|
|
||||||
|
const plans: ManagedServerPlan[] = [];
|
||||||
|
|
||||||
|
for (const item of catalog) {
|
||||||
|
if (!OFFERED_PLAN_IDS.includes(item.id ?? "")) continue;
|
||||||
|
|
||||||
|
const meta = item.metadata as Record<string, string> | null;
|
||||||
|
const cpus = Number(meta?.cpus ?? 0);
|
||||||
|
const memoryMb = Number(meta?.memory ?? 0);
|
||||||
|
const diskMb = Number(meta?.disk_space ?? 0);
|
||||||
|
const bandwidthMb = Number(meta?.bandwidth ?? 0);
|
||||||
|
|
||||||
|
const monthlyPrice = item.prices?.find(
|
||||||
|
(p) => p.period === 1 && p.period_unit === "month",
|
||||||
|
);
|
||||||
|
const annualPrice = item.prices?.find(
|
||||||
|
(p) => p.period === 1 && p.period_unit === "year",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!monthlyPrice) continue;
|
||||||
|
|
||||||
|
const hostingerMonthly = monthlyPrice.price ?? 0;
|
||||||
|
const hostingerAnnual = annualPrice?.price ?? hostingerMonthly * 12;
|
||||||
|
|
||||||
|
// Apply markup and round to nearest $0.50 (50 cents)
|
||||||
|
const dokployMonthly = Math.ceil((hostingerMonthly * MARKUP) / 50) * 50;
|
||||||
|
const dokployAnnual = Math.ceil((hostingerAnnual * MARKUP) / 50) * 50;
|
||||||
|
|
||||||
|
// Derive hostinger item IDs for monthly and annual billing
|
||||||
|
const hostingerItemIdMonthly = monthlyPrice.id ?? `${item.id}-usd-1m`;
|
||||||
|
const hostingerItemIdAnnual = annualPrice?.id ?? `${item.id}-usd-1y`;
|
||||||
|
|
||||||
|
// Map hostinger plan names to friendly names
|
||||||
|
const friendlyNames: Record<string, string> = {
|
||||||
|
"hostingercom-vps-kvm1": "Starter",
|
||||||
|
"hostingercom-vps-kvm2": "Basic",
|
||||||
|
"hostingercom-vps-kvm4": "Growth",
|
||||||
|
"hostingercom-vps-kvm8": "Scale",
|
||||||
|
};
|
||||||
|
|
||||||
|
plans.push({
|
||||||
|
id: item.id ?? "",
|
||||||
|
name: friendlyNames[item.id ?? ""] ?? item.name ?? item.id ?? "",
|
||||||
|
hostingerItemIdMonthly,
|
||||||
|
hostingerItemIdAnnual,
|
||||||
|
cpus,
|
||||||
|
memoryMb,
|
||||||
|
diskMb,
|
||||||
|
bandwidthMb,
|
||||||
|
hostingerPriceCentsMonthly: hostingerMonthly,
|
||||||
|
dokployPriceCentsMonthly: dokployMonthly,
|
||||||
|
dokployPriceCentsAnnual: dokployAnnual,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return plans;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ManagedServerPlanId = string;
|
||||||
@@ -40,7 +40,7 @@ export const readValidDirectory = (
|
|||||||
directory: string,
|
directory: string,
|
||||||
serverId?: string | null,
|
serverId?: string | null,
|
||||||
) => {
|
) => {
|
||||||
if (!/^[\w/. -]{1,500}$/.test(directory)) {
|
if (!/^[\w/. :-]{1,500}$/.test(directory)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
28
pnpm-lock.yaml
generated
28
pnpm-lock.yaml
generated
@@ -539,10 +539,10 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vite-tsconfig-paths:
|
vite-tsconfig-paths:
|
||||||
specifier: 4.3.2
|
specifier: 4.3.2
|
||||||
version: 4.3.2(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1))
|
version: 4.3.2(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1))
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.0.18
|
specifier: ^4.0.18
|
||||||
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)
|
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)
|
||||||
|
|
||||||
apps/schedules:
|
apps/schedules:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -700,6 +700,9 @@ importers:
|
|||||||
drizzle-zod:
|
drizzle-zod:
|
||||||
specifier: 0.5.1
|
specifier: 0.5.1
|
||||||
version: 0.5.1(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.18.0)(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)))(zod@4.3.6)
|
version: 0.5.1(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.18.0)(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)))(zod@4.3.6)
|
||||||
|
hostinger-api-sdk:
|
||||||
|
specifier: ^0.0.17
|
||||||
|
version: 0.0.17
|
||||||
lodash:
|
lodash:
|
||||||
specifier: 4.17.21
|
specifier: 4.17.21
|
||||||
version: 4.17.21
|
version: 4.17.21
|
||||||
@@ -5766,6 +5769,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==}
|
resolution: {integrity: sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==}
|
||||||
engines: {node: '>=16.9.0'}
|
engines: {node: '>=16.9.0'}
|
||||||
|
|
||||||
|
hostinger-api-sdk@0.0.17:
|
||||||
|
resolution: {integrity: sha512-PGIS2P4bwwvztlUHTdXYia7sAJsmDd9qsSE2tr8wDMAAjYow0J979w4dHcuHfC4ovo8nZoj3btqTDJtIIeSPYw==}
|
||||||
|
|
||||||
html-to-text@9.0.5:
|
html-to-text@9.0.5:
|
||||||
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
|
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -12321,6 +12327,7 @@ snapshots:
|
|||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 7.3.1(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)
|
vite: 7.3.1(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1))':
|
'@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -12329,7 +12336,6 @@ snapshots:
|
|||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)
|
vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@vitest/pretty-format@4.0.18':
|
'@vitest/pretty-format@4.0.18':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -12560,7 +12566,7 @@ snapshots:
|
|||||||
prisma: 7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)
|
prisma: 7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)
|
||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
react-dom: 18.2.0(react@18.2.0)
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)
|
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)
|
||||||
|
|
||||||
better-auth@1.5.4(48b68ecaf84f5e14652b8d87fbbd7ca9):
|
better-auth@1.5.4(48b68ecaf84f5e14652b8d87fbbd7ca9):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -13784,6 +13790,12 @@ snapshots:
|
|||||||
|
|
||||||
hono@4.12.2: {}
|
hono@4.12.2: {}
|
||||||
|
|
||||||
|
hostinger-api-sdk@0.0.17:
|
||||||
|
dependencies:
|
||||||
|
axios: 1.13.5
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- debug
|
||||||
|
|
||||||
html-to-text@9.0.5:
|
html-to-text@9.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@selderee/plugin-htmlparser2': 0.11.0
|
'@selderee/plugin-htmlparser2': 0.11.0
|
||||||
@@ -16384,13 +16396,13 @@ snapshots:
|
|||||||
d3-time: 3.1.0
|
d3-time: 3.1.0
|
||||||
d3-timer: 3.0.1
|
d3-timer: 3.0.1
|
||||||
|
|
||||||
vite-tsconfig-paths@4.3.2(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)):
|
vite-tsconfig-paths@4.3.2(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
globrex: 0.1.2
|
globrex: 0.1.2
|
||||||
tsconfck: 3.1.6(typescript@5.9.3)
|
tsconfck: 3.1.6(typescript@5.9.3)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 7.3.1(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)
|
vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
- typescript
|
- typescript
|
||||||
@@ -16409,6 +16421,7 @@ snapshots:
|
|||||||
jiti: 1.21.7
|
jiti: 1.21.7
|
||||||
tsx: 4.16.2
|
tsx: 4.16.2
|
||||||
yaml: 2.8.1
|
yaml: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1):
|
vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -16424,7 +16437,6 @@ snapshots:
|
|||||||
jiti: 2.6.1
|
jiti: 2.6.1
|
||||||
tsx: 4.16.2
|
tsx: 4.16.2
|
||||||
yaml: 2.8.1
|
yaml: 2.8.1
|
||||||
optional: true
|
|
||||||
|
|
||||||
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1):
|
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -16463,6 +16475,7 @@ snapshots:
|
|||||||
- terser
|
- terser
|
||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- yaml
|
||||||
|
optional: true
|
||||||
|
|
||||||
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1):
|
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -16501,7 +16514,6 @@ snapshots:
|
|||||||
- terser
|
- terser
|
||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- yaml
|
||||||
optional: true
|
|
||||||
|
|
||||||
w3c-keyname@2.2.8: {}
|
w3c-keyname@2.2.8: {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user