mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-01 12:05:23 +02:00
Compare commits
54 Commits
feat/docke
...
v0.29.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a271c11e7 | ||
|
|
fda367b2c5 | ||
|
|
ea1238b1d1 | ||
|
|
b060f80932 | ||
|
|
04b9f56333 | ||
|
|
599b97da51 | ||
|
|
415298fddb | ||
|
|
ddff8b9de7 | ||
|
|
90f97912a4 | ||
|
|
9af745ce67 | ||
|
|
d99f2cd460 | ||
|
|
d234558822 | ||
|
|
7f25ddca44 | ||
|
|
638b3dd546 | ||
|
|
1a8fd8396d | ||
|
|
385850f354 | ||
|
|
a48306a2c6 | ||
|
|
89737e7b65 | ||
|
|
00c708483e | ||
|
|
ddf570a807 | ||
|
|
f8eb2ba4ba | ||
|
|
9f07f8e9e1 | ||
|
|
3cefa43a21 | ||
|
|
0941ec9f3e | ||
|
|
879218a8b1 | ||
|
|
d6124aae81 | ||
|
|
f404b231a6 | ||
|
|
7a986e5fb3 | ||
|
|
9687ed0d83 | ||
|
|
b4c57b6326 | ||
|
|
f8eb3c2b76 | ||
|
|
a30617d85d | ||
|
|
b079cbd427 | ||
|
|
aeda19db8a | ||
|
|
cb64482649 | ||
|
|
f4cae5f775 | ||
|
|
825e6b654c | ||
|
|
c1b19376a9 | ||
|
|
6c3578a475 | ||
|
|
b8db120432 | ||
|
|
7c10610a5a | ||
|
|
8d8658a478 | ||
|
|
fbde5be02c | ||
|
|
090c0226ed | ||
|
|
4a1b42899b | ||
|
|
343514d4eb | ||
|
|
36067618f4 | ||
|
|
cc74f9e38c | ||
|
|
df7e1da776 | ||
|
|
df9aa50ece | ||
|
|
d9b2b48643 | ||
|
|
148c91bf5e | ||
|
|
4ef8c94340 | ||
|
|
ff369c9d3a |
42
.github/workflows/sync-openapi-docs.yml
vendored
42
.github/workflows/sync-openapi-docs.yml
vendored
@@ -68,3 +68,45 @@ jobs:
|
|||||||
|
|
||||||
echo "✅ OpenAPI synced to website successfully"
|
echo "✅ OpenAPI synced to website successfully"
|
||||||
|
|
||||||
|
- name: Sync to MCP repository
|
||||||
|
run: |
|
||||||
|
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git mcp-repo
|
||||||
|
|
||||||
|
cd mcp-repo
|
||||||
|
|
||||||
|
cp -f ../openapi.json openapi.json
|
||||||
|
|
||||||
|
git config user.name "Dokploy Bot"
|
||||||
|
git config user.email "bot@dokploy.com"
|
||||||
|
|
||||||
|
git add openapi.json
|
||||||
|
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
||||||
|
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||||
|
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
||||||
|
--allow-empty
|
||||||
|
|
||||||
|
git push
|
||||||
|
|
||||||
|
echo "✅ OpenAPI synced to MCP repository successfully"
|
||||||
|
|
||||||
|
- name: Sync to CLI repository
|
||||||
|
run: |
|
||||||
|
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git cli-repo
|
||||||
|
|
||||||
|
cd cli-repo
|
||||||
|
|
||||||
|
cp -f ../openapi.json openapi.json
|
||||||
|
|
||||||
|
git config user.name "Dokploy Bot"
|
||||||
|
git config user.email "bot@dokploy.com"
|
||||||
|
|
||||||
|
git add openapi.json
|
||||||
|
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
||||||
|
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||||
|
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
||||||
|
--allow-empty
|
||||||
|
|
||||||
|
git push
|
||||||
|
|
||||||
|
echo "✅ OpenAPI synced to CLI repository successfully"
|
||||||
|
|
||||||
|
|||||||
79
.github/workflows/sync-version.yml
vendored
Normal file
79
.github/workflows/sync-version.yml
vendored
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
name: Sync version to MCP and CLI repos
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
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)
|
||||||
|
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 mcp-repo
|
||||||
|
cd mcp-repo
|
||||||
|
|
||||||
|
# Bump version
|
||||||
|
jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp
|
||||||
|
mv package.json.tmp package.json
|
||||||
|
|
||||||
|
# Regenerate tools from latest OpenAPI spec
|
||||||
|
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 ${{ 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 cli-repo
|
||||||
|
|
||||||
|
cd cli-repo
|
||||||
|
|
||||||
|
# Bump version
|
||||||
|
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
|
||||||
|
|
||||||
|
# Copy latest openapi spec and regenerate commands
|
||||||
|
cp ../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 ${{ 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 }}"
|
||||||
|
|
||||||
@@ -424,6 +424,26 @@ test("Custom entrypoint with internalPath adds addprefix middleware", async () =
|
|||||||
expect(router.entryPoints).toEqual(["custom"]);
|
expect(router.entryPoints).toEqual(["custom"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("stripPath and internalPath together: stripprefix must come before addprefix", async () => {
|
||||||
|
const router = await createRouterConfig(
|
||||||
|
baseApp,
|
||||||
|
{
|
||||||
|
...baseDomain,
|
||||||
|
path: "/public",
|
||||||
|
stripPath: true,
|
||||||
|
internalPath: "/app/v2",
|
||||||
|
},
|
||||||
|
"web",
|
||||||
|
);
|
||||||
|
|
||||||
|
const stripIndex = router.middlewares?.indexOf("stripprefix--1") ?? -1;
|
||||||
|
const addIndex = router.middlewares?.indexOf("addprefix--1") ?? -1;
|
||||||
|
|
||||||
|
expect(stripIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(addIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(stripIndex).toBeLessThan(addIndex);
|
||||||
|
});
|
||||||
|
|
||||||
test("Custom entrypoint with https and custom cert resolver", async () => {
|
test("Custom entrypoint with https and custom cert resolver", async () => {
|
||||||
const router = await createRouterConfig(
|
const router = await createRouterConfig(
|
||||||
baseApp,
|
baseApp,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
import { Check, Copy, Loader2 } from "lucide-react";
|
import { Check, Copy, Loader2 } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { AnalyzeLogs } from "@/components/dashboard/docker/logs/analyze-logs";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
@@ -165,6 +166,7 @@ export const ShowDeployment = ({
|
|||||||
<Copy className="h-3.5 w-3.5" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
<AnalyzeLogs logs={filteredLogs} context="build" />
|
||||||
|
|
||||||
{serverId && (
|
{serverId && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ interface Props {
|
|||||||
|
|
||||||
export const SaveGitProvider = ({ applicationId }: Props) => {
|
export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
const { data: sshKeys } = api.sshKey.allForApps.useQuery();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { mutateAsync, isPending } =
|
const { mutateAsync, isPending } =
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { Loader2, MoreHorizontal, RefreshCw } from "lucide-react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { ShowContainerConfig } from "@/components/dashboard/docker/config/show-container-config";
|
||||||
|
import { ShowContainerMounts } from "@/components/dashboard/docker/mounts/show-container-mounts";
|
||||||
|
import { ShowContainerNetworks } from "@/components/dashboard/docker/networks/show-container-networks";
|
||||||
|
import { DockerTerminalModal } from "@/components/dashboard/docker/terminal/docker-terminal-modal";
|
||||||
|
|
||||||
|
const DockerLogsId = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/dashboard/docker/logs/docker-logs-id").then(
|
||||||
|
(e) => e.DockerLogsId,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
appName: string;
|
||||||
|
serverId?: string;
|
||||||
|
appType: "stack" | "docker-compose";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShowComposeContainers = ({
|
||||||
|
appName,
|
||||||
|
appType,
|
||||||
|
serverId,
|
||||||
|
}: Props) => {
|
||||||
|
const { data, isPending, refetch } =
|
||||||
|
api.docker.getContainersByAppNameMatch.useQuery(
|
||||||
|
{
|
||||||
|
appName,
|
||||||
|
appType,
|
||||||
|
serverId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!appName,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-background">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-xl">Containers</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Inspect each container in this compose and run basic lifecycle
|
||||||
|
actions.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isPending ? "animate-spin" : ""}`} />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isPending ? (
|
||||||
|
<div className="flex items-center justify-center h-[20vh]">
|
||||||
|
<Loader2 className="animate-spin h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : !data || data.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-[20vh]">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
No containers found. Deploy the compose to see containers here.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>State</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Container ID</TableHead>
|
||||||
|
<TableHead className="text-right" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.map((container) => (
|
||||||
|
<ContainerRow
|
||||||
|
key={container.containerId}
|
||||||
|
container={container}
|
||||||
|
serverId={serverId}
|
||||||
|
onActionComplete={() => refetch()}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ContainerRowProps {
|
||||||
|
container: {
|
||||||
|
containerId: string;
|
||||||
|
name: string;
|
||||||
|
state: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
serverId?: string;
|
||||||
|
onActionComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContainerRow = ({
|
||||||
|
container,
|
||||||
|
serverId,
|
||||||
|
onActionComplete,
|
||||||
|
}: ContainerRowProps) => {
|
||||||
|
const [logsOpen, setLogsOpen] = useState(false);
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const restartMutation = api.docker.restartContainer.useMutation();
|
||||||
|
const startMutation = api.docker.startContainer.useMutation();
|
||||||
|
const stopMutation = api.docker.stopContainer.useMutation();
|
||||||
|
const killMutation = api.docker.killContainer.useMutation();
|
||||||
|
|
||||||
|
const handleAction = async (
|
||||||
|
action: string,
|
||||||
|
mutationFn: typeof restartMutation,
|
||||||
|
) => {
|
||||||
|
setActionLoading(action);
|
||||||
|
try {
|
||||||
|
await mutationFn.mutateAsync({
|
||||||
|
containerId: container.containerId,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
toast.success(`Container ${action} successfully`);
|
||||||
|
onActionComplete();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
`Failed to ${action} container: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-medium">{container.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
container.state === "running"
|
||||||
|
? "default"
|
||||||
|
: container.state === "exited"
|
||||||
|
? "secondary"
|
||||||
|
: "destructive"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{container.state}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{container.status}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm text-muted-foreground">
|
||||||
|
{container.containerId}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Dialog open={logsOpen} onOpenChange={setLogsOpen}>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
{actionLoading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
View Logs
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogTrigger>
|
||||||
|
<ShowContainerConfig
|
||||||
|
containerId={container.containerId}
|
||||||
|
serverId={serverId || ""}
|
||||||
|
/>
|
||||||
|
<ShowContainerMounts
|
||||||
|
containerId={container.containerId}
|
||||||
|
serverId={serverId || ""}
|
||||||
|
/>
|
||||||
|
<ShowContainerNetworks
|
||||||
|
containerId={container.containerId}
|
||||||
|
serverId={serverId || ""}
|
||||||
|
/>
|
||||||
|
<DockerTerminalModal
|
||||||
|
containerId={container.containerId}
|
||||||
|
serverId={serverId || ""}
|
||||||
|
>
|
||||||
|
Terminal
|
||||||
|
</DockerTerminalModal>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
onClick={() => handleAction("restart", restartMutation)}
|
||||||
|
>
|
||||||
|
Restart
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
onClick={() => handleAction("start", startMutation)}
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
onClick={() => handleAction("stop", stopMutation)}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer text-red-500 focus:text-red-600"
|
||||||
|
disabled={actionLoading !== null}
|
||||||
|
onClick={() => handleAction("kill", killMutation)}
|
||||||
|
>
|
||||||
|
Kill
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<DialogContent className="sm:max-w-7xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>View Logs</DialogTitle>
|
||||||
|
<DialogDescription>Logs for {container.name}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
|
<DockerLogsId
|
||||||
|
containerId={container.containerId}
|
||||||
|
serverId={serverId}
|
||||||
|
runType="native"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -55,7 +55,7 @@ interface Props {
|
|||||||
|
|
||||||
export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||||
const { data, refetch } = api.compose.one.useQuery({ composeId });
|
const { data, refetch } = api.compose.one.useQuery({ composeId });
|
||||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
const { data: sshKeys } = api.sshKey.allForApps.useQuery();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { mutateAsync, isPending } = api.compose.update.useMutation();
|
const { mutateAsync, isPending } = api.compose.update.useMutation();
|
||||||
|
|||||||
189
apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx
Normal file
189
apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"use client";
|
||||||
|
import { Bot, Loader2, RotateCcw, Settings, X } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import type { LogLine } from "./utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
logs: LogLine[];
|
||||||
|
context: "build" | "runtime";
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_LOG_LINES = 200;
|
||||||
|
|
||||||
|
export function AnalyzeLogs({ logs, context }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [aiId, setAiId] = useState<string>("");
|
||||||
|
const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, {
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
const { mutate, isPending, data, reset } = api.ai.analyzeLogs.useMutation({
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Analysis failed", {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAnalyze = () => {
|
||||||
|
if (!aiId || logs.length === 0) return;
|
||||||
|
|
||||||
|
const logsText = logs
|
||||||
|
.slice(-MAX_LOG_LINES)
|
||||||
|
.map((l) => l.message)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
mutate({ aiId, logs: logsText, context });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
setOpen(isOpen);
|
||||||
|
if (!isOpen) {
|
||||||
|
reset();
|
||||||
|
setAiId("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9"
|
||||||
|
disabled={logs.length === 0}
|
||||||
|
title="Analyze logs with AI"
|
||||||
|
>
|
||||||
|
<Bot className="mr-2 h-4 w-4" />
|
||||||
|
AI
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[550px] p-0" align="end">
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bot className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">Log Analysis</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{!data?.analysis ? (
|
||||||
|
providers && providers.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-2 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No AI providers configured. Set up a provider to start
|
||||||
|
analyzing logs.
|
||||||
|
</p>
|
||||||
|
<Button size="sm" variant="outline" asChild>
|
||||||
|
<Link href="/dashboard/settings/ai">
|
||||||
|
<Settings className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Configure AI Provider
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Select value={aiId} onValueChange={setAiId}>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue placeholder="Select AI provider..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{providers?.map((p) => (
|
||||||
|
<SelectItem key={p.aiId} value={p.aiId}>
|
||||||
|
{p.name} ({p.model})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
disabled={!aiId || isPending || logs.length === 0}
|
||||||
|
onClick={handleAnalyze}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||||
|
Analyzing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Bot className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Analyze{" "}
|
||||||
|
{logs.length > MAX_LOG_LINES
|
||||||
|
? `last ${MAX_LOG_LINES}`
|
||||||
|
: logs.length}{" "}
|
||||||
|
lines
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="max-h-[400px] overflow-y-auto">
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-w-none text-sm break-words">
|
||||||
|
<ReactMarkdown>{data.analysis}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => {
|
||||||
|
reset();
|
||||||
|
handleAnalyze();
|
||||||
|
}}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Re-analyze
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
reset();
|
||||||
|
setAiId("");
|
||||||
|
}}
|
||||||
|
title="Change provider"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { AlertBlock } from "@/components/shared/alert-block";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { AnalyzeLogs } from "./analyze-logs";
|
||||||
import { LineCountFilter } from "./line-count-filter";
|
import { LineCountFilter } from "./line-count-filter";
|
||||||
import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter";
|
import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter";
|
||||||
import { StatusLogsFilter } from "./status-logs-filter";
|
import { StatusLogsFilter } from "./status-logs-filter";
|
||||||
@@ -377,6 +378,7 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||||
Download logs
|
Download logs
|
||||||
</Button>
|
</Button>
|
||||||
|
<AnalyzeLogs logs={filteredLogs} context="runtime" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isPaused && (
|
{isPaused && (
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
containerId: string;
|
||||||
|
serverId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Mount {
|
||||||
|
Type: string;
|
||||||
|
Source: string;
|
||||||
|
Destination: string;
|
||||||
|
Mode: string;
|
||||||
|
RW: boolean;
|
||||||
|
Propagation: string;
|
||||||
|
Name?: string;
|
||||||
|
Driver?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShowContainerMounts = ({ containerId, serverId }: Props) => {
|
||||||
|
const { data } = api.docker.getConfig.useQuery(
|
||||||
|
{
|
||||||
|
containerId,
|
||||||
|
serverId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!containerId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const mounts: Mount[] = data?.Mounts ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="w-full cursor-pointer"
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
View Mounts
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Container Mounts</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Volume and bind mounts for this container
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="overflow-auto max-h-[70vh]">
|
||||||
|
{mounts.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8">
|
||||||
|
No mounts found for this container.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Source</TableHead>
|
||||||
|
<TableHead>Destination</TableHead>
|
||||||
|
<TableHead>Mode</TableHead>
|
||||||
|
<TableHead>Read/Write</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{mounts.map((mount, index) => (
|
||||||
|
<TableRow key={index}>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{mount.Type}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs max-w-[250px] truncate">
|
||||||
|
{mount.Name || mount.Source}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs max-w-[250px] truncate">
|
||||||
|
{mount.Destination}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{mount.Mode || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={mount.RW ? "default" : "secondary"}>
|
||||||
|
{mount.RW ? "RW" : "RO"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
containerId: string;
|
||||||
|
serverId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Network {
|
||||||
|
IPAMConfig: unknown;
|
||||||
|
Links: unknown;
|
||||||
|
Aliases: string[] | null;
|
||||||
|
MacAddress: string;
|
||||||
|
NetworkID: string;
|
||||||
|
EndpointID: string;
|
||||||
|
Gateway: string;
|
||||||
|
IPAddress: string;
|
||||||
|
IPPrefixLen: number;
|
||||||
|
IPv6Gateway: string;
|
||||||
|
GlobalIPv6Address: string;
|
||||||
|
GlobalIPv6PrefixLen: number;
|
||||||
|
DriverOpts: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShowContainerNetworks = ({ containerId, serverId }: Props) => {
|
||||||
|
const { data } = api.docker.getConfig.useQuery(
|
||||||
|
{
|
||||||
|
containerId,
|
||||||
|
serverId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!containerId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const networks: Record<string, Network> =
|
||||||
|
data?.NetworkSettings?.Networks ?? {};
|
||||||
|
const entries = Object.entries(networks);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="w-full cursor-pointer"
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
View Networks
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Container Networks</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Networks attached to this container
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="overflow-auto max-h-[70vh]">
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8">
|
||||||
|
No networks found for this container.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Network</TableHead>
|
||||||
|
<TableHead>IP Address</TableHead>
|
||||||
|
<TableHead>Gateway</TableHead>
|
||||||
|
<TableHead>MAC Address</TableHead>
|
||||||
|
<TableHead>Aliases</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{entries.map(([name, network]) => (
|
||||||
|
<TableRow key={name}>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{name}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{network.IPAddress
|
||||||
|
? `${network.IPAddress}/${network.IPPrefixLen}`
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{network.Gateway || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{network.MacAddress || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{network.Aliases?.join(", ") || "-"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { ShowContainerConfig } from "../config/show-container-config";
|
import { ShowContainerConfig } from "../config/show-container-config";
|
||||||
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
|
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
|
||||||
|
import { ShowContainerMounts } from "../mounts/show-container-mounts";
|
||||||
|
import { ShowContainerNetworks } from "../networks/show-container-networks";
|
||||||
import { RemoveContainerDialog } from "../remove/remove-container";
|
import { RemoveContainerDialog } from "../remove/remove-container";
|
||||||
import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
|
import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
|
||||||
import { UploadFileModal } from "../upload/upload-file-modal";
|
import { UploadFileModal } from "../upload/upload-file-modal";
|
||||||
@@ -123,6 +125,14 @@ export const columns: ColumnDef<Container>[] = [
|
|||||||
containerId={container.containerId}
|
containerId={container.containerId}
|
||||||
serverId={container.serverId || ""}
|
serverId={container.serverId || ""}
|
||||||
/>
|
/>
|
||||||
|
<ShowContainerMounts
|
||||||
|
containerId={container.containerId}
|
||||||
|
serverId={container.serverId || ""}
|
||||||
|
/>
|
||||||
|
<ShowContainerNetworks
|
||||||
|
containerId={container.containerId}
|
||||||
|
serverId={container.serverId || ""}
|
||||||
|
/>
|
||||||
<DockerTerminalModal
|
<DockerTerminalModal
|
||||||
containerId={container.containerId}
|
containerId={container.containerId}
|
||||||
serverId={container.serverId || ""}
|
serverId={container.serverId || ""}
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
|
|||||||
const buildConnectionUrl = () => {
|
const buildConnectionUrl = () => {
|
||||||
const port = form.watch("externalPort") || data?.externalPort;
|
const port = form.watch("externalPort") || data?.externalPort;
|
||||||
|
|
||||||
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`;
|
const params = `authSource=admin${data?.replicaSets ? "" : "&directConnection=true"}`;
|
||||||
|
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/?${params}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
setConnectionUrl(buildConnectionUrl());
|
setConnectionUrl(buildConnectionUrl());
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
|
|||||||
<Label>Internal Connection URL </Label>
|
<Label>Internal Connection URL </Label>
|
||||||
<ToggleVisibilityInput
|
<ToggleVisibilityInput
|
||||||
disabled
|
disabled
|
||||||
value={`mongodb://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:27017`}
|
value={`mongodb://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:27017/?authSource=admin${data?.replicaSets ? "" : "&directConnection=true"}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -220,11 +220,11 @@ export const ContainerFreeMonitoring = ({
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-col gap-2 w-full">
|
<div className="flex flex-col gap-2 w-full">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Used: {currentData.cpu.value}
|
Used: {String(currentData.cpu.value ?? "0%")}
|
||||||
</span>
|
</span>
|
||||||
<Progress
|
<Progress
|
||||||
value={Number.parseInt(
|
value={Number.parseInt(
|
||||||
currentData.cpu.value.replace("%", ""),
|
String(currentData.cpu.value ?? "0%").replace("%", ""),
|
||||||
10,
|
10,
|
||||||
)}
|
)}
|
||||||
className="w-[100%]"
|
className="w-[100%]"
|
||||||
|
|||||||
@@ -298,7 +298,19 @@ export const TemplateGenerator = ({ environmentId }: Props) => {
|
|||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
<div className="flex items-center gap-2 w-full justify-end">
|
<div className="flex items-center gap-2 w-full justify-end">
|
||||||
<Button
|
<Button
|
||||||
onClick={stepper.prev}
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
stepper.current.id === "variant" &&
|
||||||
|
templateInfo.details
|
||||||
|
) {
|
||||||
|
setTemplateInfo((prev) => ({
|
||||||
|
...prev,
|
||||||
|
details: null,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stepper.prev();
|
||||||
|
}}
|
||||||
disabled={stepper.isFirst}
|
disabled={stepper.isFirst}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { loadStripe } from "@stripe/stripe-js";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Bell,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -25,7 +26,17 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { NumberInput } from "@/components/ui/input";
|
import { NumberInput } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
@@ -90,6 +101,8 @@ export const ShowBilling = () => {
|
|||||||
api.stripe.createCustomerPortalSession.useMutation();
|
api.stripe.createCustomerPortalSession.useMutation();
|
||||||
const { mutateAsync: upgradeSubscription, isPending: isUpgrading } =
|
const { mutateAsync: upgradeSubscription, isPending: isUpgrading } =
|
||||||
api.stripe.upgradeSubscription.useMutation();
|
api.stripe.upgradeSubscription.useMutation();
|
||||||
|
const { mutateAsync: updateInvoiceNotifications } =
|
||||||
|
api.stripe.updateInvoiceNotifications.useMutation();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
|
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
|
||||||
@@ -151,14 +164,66 @@ export const ShowBilling = () => {
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Card className="bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto">
|
<Card className="bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto">
|
||||||
<div className="rounded-xl bg-background shadow-md">
|
<div className="rounded-xl bg-background shadow-md">
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-start justify-between">
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<div>
|
||||||
<CreditCard className="size-6 text-muted-foreground self-center" />
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
Billing
|
<CreditCard className="size-6 text-muted-foreground self-center" />
|
||||||
</CardTitle>
|
Billing
|
||||||
<CardDescription>
|
</CardTitle>
|
||||||
Manage your subscription and invoices
|
<CardDescription>
|
||||||
</CardDescription>
|
Manage your subscription and invoices
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<Bell className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Notification Settings</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure your billing email notifications.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="invoice-notifications">
|
||||||
|
Invoice Notifications
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Receive email notifications for payments and failed
|
||||||
|
charges.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="invoice-notifications"
|
||||||
|
checked={admin?.user.sendInvoiceNotifications ?? false}
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
await updateInvoiceNotifications({
|
||||||
|
enabled: checked,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
utils.user.get.invalidate();
|
||||||
|
toast.success(
|
||||||
|
checked
|
||||||
|
? "Invoice notifications enabled"
|
||||||
|
: "Invoice notifications disabled",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error(
|
||||||
|
"Failed to update invoice notifications",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 py-4 border-t">
|
<CardContent className="space-y-4 py-4 border-t">
|
||||||
<nav className="flex space-x-2 border-b">
|
<nav className="flex space-x-2 border-b">
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { Check, ChevronDown, PenBoxIcon, PlusIcon } from "lucide-react";
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
Loader2,
|
||||||
|
PenBoxIcon,
|
||||||
|
Plug,
|
||||||
|
PlusIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -37,10 +44,34 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const AI_PROVIDERS = [
|
||||||
|
{ name: "OpenAI", apiUrl: "https://api.openai.com/v1" },
|
||||||
|
{ name: "Anthropic", apiUrl: "https://api.anthropic.com/v1" },
|
||||||
|
{
|
||||||
|
name: "Google Gemini",
|
||||||
|
apiUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||||||
|
},
|
||||||
|
{ name: "Mistral", apiUrl: "https://api.mistral.ai/v1" },
|
||||||
|
{ name: "Cohere", apiUrl: "https://api.cohere.ai/v2" },
|
||||||
|
{ name: "Perplexity", apiUrl: "https://api.perplexity.ai" },
|
||||||
|
{ name: "DeepInfra", apiUrl: "https://api.deepinfra.com/v1/openai" },
|
||||||
|
{ name: "Ollama", apiUrl: "http://localhost:11434" },
|
||||||
|
{ name: "OpenRouter", apiUrl: "https://openrouter.ai/api/v1" },
|
||||||
|
{ name: "Z.AI", apiUrl: "https://api.z.ai/api/paas/v4" },
|
||||||
|
{ name: "MiniMax", apiUrl: "https://api.minimax.io/v1" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
name: z.string().min(1, { message: "Name is required" }),
|
name: z.string().min(1, { message: "Name is required" }),
|
||||||
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
|
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
|
||||||
@@ -103,7 +134,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
const isOllama = apiUrl.includes(":11434") || apiUrl.includes("ollama");
|
const isOllama = apiUrl.includes(":11434") || apiUrl.includes("ollama");
|
||||||
const {
|
const {
|
||||||
data: models,
|
data: models,
|
||||||
isPending: isLoadingServerModels,
|
isFetching: isLoadingServerModels,
|
||||||
error: modelsError,
|
error: modelsError,
|
||||||
} = api.ai.getModels.useQuery(
|
} = api.ai.getModels.useQuery(
|
||||||
{
|
{
|
||||||
@@ -172,6 +203,34 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
<AlertBlock type="error">{modelsError.message}</AlertBlock>
|
<AlertBlock type="error">{modelsError.message}</AlertBlock>
|
||||||
)}
|
)}
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<FormLabel>Provider</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const provider = AI_PROVIDERS.find((p) => p.apiUrl === value);
|
||||||
|
if (provider) {
|
||||||
|
form.setValue("name", provider.name);
|
||||||
|
form.setValue("apiUrl", provider.apiUrl);
|
||||||
|
form.setValue("model", "");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a provider preset..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{AI_PROVIDERS.map((provider) => (
|
||||||
|
<SelectItem key={provider.apiUrl} value={provider.apiUrl}>
|
||||||
|
{provider.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[0.8rem] text-muted-foreground">
|
||||||
|
Quick-fill provider name and URL, or configure manually below
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
@@ -253,101 +312,129 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoadingServerModels && !models?.length && (
|
<FormField
|
||||||
<span className="text-sm text-muted-foreground">
|
control={form.control}
|
||||||
No models available
|
name="model"
|
||||||
</span>
|
render={({ field }) => {
|
||||||
)}
|
const hasModels =
|
||||||
|
!isLoadingServerModels && models && models.length > 0;
|
||||||
|
const selectedModel = models?.find((m) => m.id === field.value);
|
||||||
|
const filteredModels = (models ?? []).filter((model) =>
|
||||||
|
model.id.toLowerCase().includes(modelSearch.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
{!isLoadingServerModels && models && models.length > 0 && (
|
const displayModels =
|
||||||
<FormField
|
field.value &&
|
||||||
control={form.control}
|
!filteredModels.find((m) => m.id === field.value) &&
|
||||||
name="model"
|
selectedModel
|
||||||
render={({ field }) => {
|
? [selectedModel, ...filteredModels]
|
||||||
const selectedModel = models.find(
|
: filteredModels;
|
||||||
(m) => m.id === field.value,
|
|
||||||
);
|
|
||||||
const filteredModels = models.filter((model) =>
|
|
||||||
model.id.toLowerCase().includes(modelSearch.toLowerCase()),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ensure selected model is always in the filtered list
|
return (
|
||||||
const displayModels =
|
<FormItem>
|
||||||
field.value &&
|
<FormLabel>Model</FormLabel>
|
||||||
!filteredModels.find((m) => m.id === field.value) &&
|
<div className="flex gap-2">
|
||||||
selectedModel
|
<div className="flex-1">
|
||||||
? [selectedModel, ...filteredModels]
|
{hasModels ? (
|
||||||
: filteredModels;
|
<Popover
|
||||||
|
open={modelPopoverOpen}
|
||||||
return (
|
onOpenChange={setModelPopoverOpen}
|
||||||
<FormItem>
|
>
|
||||||
<FormLabel>Model</FormLabel>
|
<PopoverTrigger asChild>
|
||||||
<Popover
|
<FormControl>
|
||||||
open={modelPopoverOpen}
|
<Button
|
||||||
onOpenChange={setModelPopoverOpen}
|
variant="outline"
|
||||||
>
|
className={cn(
|
||||||
<PopoverTrigger asChild>
|
"w-full justify-between",
|
||||||
<FormControl>
|
!field.value && "text-muted-foreground",
|
||||||
<Button
|
)}
|
||||||
variant="outline"
|
>
|
||||||
className={cn(
|
{field.value
|
||||||
"w-full justify-between",
|
? (selectedModel?.id ?? field.value)
|
||||||
!field.value && "text-muted-foreground",
|
: "Select a model"}
|
||||||
)}
|
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-[400px] p-0"
|
||||||
|
align="start"
|
||||||
>
|
>
|
||||||
{field.value
|
<Command>
|
||||||
? (selectedModel?.id ?? field.value)
|
<CommandInput
|
||||||
: "Select a model"}
|
placeholder="Search or type a custom model..."
|
||||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
value={modelSearch}
|
||||||
</Button>
|
onValueChange={setModelSearch}
|
||||||
</FormControl>
|
/>
|
||||||
</PopoverTrigger>
|
<CommandList>
|
||||||
<PopoverContent className="w-[400px] p-0" align="start">
|
<CommandEmpty>
|
||||||
<Command>
|
{modelSearch ? (
|
||||||
<CommandInput
|
<button
|
||||||
placeholder="Search models..."
|
type="button"
|
||||||
value={modelSearch}
|
className="w-full cursor-pointer px-2 py-1.5 text-left text-sm hover:bg-accent"
|
||||||
onValueChange={setModelSearch}
|
onClick={() => {
|
||||||
|
field.onChange(modelSearch);
|
||||||
|
setModelPopoverOpen(false);
|
||||||
|
setModelSearch("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use custom model: "{modelSearch}"
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
"No models found."
|
||||||
|
)}
|
||||||
|
</CommandEmpty>
|
||||||
|
{displayModels.map((model) => {
|
||||||
|
const isSelected = field.value === model.id;
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={model.id}
|
||||||
|
value={model.id}
|
||||||
|
onSelect={() => {
|
||||||
|
field.onChange(model.id);
|
||||||
|
setModelPopoverOpen(false);
|
||||||
|
setModelSearch("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
isSelected
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{model.id}
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
) : (
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder={
|
||||||
|
isLoadingServerModels
|
||||||
|
? "Loading models..."
|
||||||
|
: "Enter model name (e.g. gpt-4o)"
|
||||||
|
}
|
||||||
|
disabled={isLoadingServerModels}
|
||||||
|
{...field}
|
||||||
/>
|
/>
|
||||||
<CommandList>
|
</FormControl>
|
||||||
<CommandEmpty>No models found.</CommandEmpty>
|
)}
|
||||||
{displayModels.map((model) => {
|
</div>
|
||||||
const isSelected = field.value === model.id;
|
</div>
|
||||||
return (
|
<FormDescription>
|
||||||
<CommandItem
|
Select a model from the list or type a custom model name
|
||||||
key={model.id}
|
</FormDescription>
|
||||||
value={model.id}
|
<FormMessage />
|
||||||
onSelect={() => {
|
</FormItem>
|
||||||
field.onChange(model.id);
|
);
|
||||||
setModelPopoverOpen(false);
|
}}
|
||||||
setModelSearch("");
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
isSelected
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{model.id}
|
|
||||||
</CommandItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
<FormDescription>
|
|
||||||
Select an AI model to use
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -372,7 +459,12 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-4">
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<TestConnectionButton
|
||||||
|
apiUrl={apiUrl}
|
||||||
|
apiKey={apiKey}
|
||||||
|
model={form.watch("model")}
|
||||||
|
/>
|
||||||
<Button type="submit" isLoading={isPending}>
|
<Button type="submit" isLoading={isPending}>
|
||||||
{aiId ? "Update" : "Create"}
|
{aiId ? "Update" : "Create"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -383,3 +475,42 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function TestConnectionButton({
|
||||||
|
apiUrl,
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
}: {
|
||||||
|
apiUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
}) {
|
||||||
|
const { mutate, isPending } = api.ai.testConnection.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Connection successful");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Connection failed", {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isDisabled = !apiUrl || !model;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isDisabled || isPending}
|
||||||
|
onClick={() => mutate({ apiUrl, apiKey, model })}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plug className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Test Connection
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ export const WelcomeSubscription = () => {
|
|||||||
const [showConfetti, setShowConfetti] = useState(false);
|
const [showConfetti, setShowConfetti] = useState(false);
|
||||||
const stepper = useStepper();
|
const stepper = useStepper();
|
||||||
const [isOpen, setIsOpen] = useState(true);
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
const { push } = useRouter();
|
const router = useRouter();
|
||||||
|
const { push } = router;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const confettiShown = localStorage.getItem("hasShownConfetti");
|
const confettiShown = localStorage.getItem("hasShownConfetti");
|
||||||
@@ -66,7 +67,22 @@ export const WelcomeSubscription = () => {
|
|||||||
}, [showConfetti]);
|
}, [showConfetti]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen}>
|
<Dialog
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
const { success, ...rest } = router.query;
|
||||||
|
router.replace(
|
||||||
|
{ pathname: router.pathname, query: rest },
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
shallow: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogContent className="sm:max-w-7xl min-h-[75vh]">
|
<DialogContent className="sm:max-w-7xl min-h-[75vh]">
|
||||||
{showConfetti ?? "Flaso"}
|
{showConfetti ?? "Flaso"}
|
||||||
<div className="flex justify-center items-center w-full">
|
<div className="flex justify-center items-center w-full">
|
||||||
|
|||||||
@@ -116,6 +116,14 @@ export function TagSelector({
|
|||||||
<HandleTag />
|
<HandleTag />
|
||||||
</div>
|
</div>
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
|
{tags.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-4">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
No tags created yet.
|
||||||
|
</span>
|
||||||
|
<HandleTag />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{tags.map((tag) => {
|
{tags.map((tag) => {
|
||||||
const isSelected = selectedTags.includes(tag.id);
|
const isSelected = selectedTags.includes(tag.id);
|
||||||
|
|||||||
198
apps/dokploy/components/ui/context-menu.tsx
Normal file
198
apps/dokploy/components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const ContextMenu = ContextMenuPrimitive.Root;
|
||||||
|
|
||||||
|
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
|
||||||
|
|
||||||
|
const ContextMenuGroup = ContextMenuPrimitive.Group;
|
||||||
|
|
||||||
|
const ContextMenuPortal = ContextMenuPrimitive.Portal;
|
||||||
|
|
||||||
|
const ContextMenuSub = ContextMenuPrimitive.Sub;
|
||||||
|
|
||||||
|
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
|
const ContextMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||||
|
inset && "pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
|
));
|
||||||
|
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
|
const ContextMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
|
const ContextMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
));
|
||||||
|
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const ContextMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const ContextMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
|
));
|
||||||
|
ContextMenuCheckboxItem.displayName =
|
||||||
|
ContextMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
|
const ContextMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.RadioItem>
|
||||||
|
));
|
||||||
|
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
|
const ContextMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||||
|
inset && "pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const ContextMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const ContextMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
ContextMenuShortcut.displayName = "ContextMenuShortcut";
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
};
|
||||||
1
apps/dokploy/drizzle/0165_abnormal_greymalkin.sql
Normal file
1
apps/dokploy/drizzle/0165_abnormal_greymalkin.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "user" ADD COLUMN "sendInvoiceNotifications" boolean DEFAULT false NOT NULL;
|
||||||
8312
apps/dokploy/drizzle/meta/0165_snapshot.json
Normal file
8312
apps/dokploy/drizzle/meta/0165_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1156,6 +1156,13 @@
|
|||||||
"when": 1775369858244,
|
"when": 1775369858244,
|
||||||
"tag": "0164_slippery_sasquatch",
|
"tag": "0164_slippery_sasquatch",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 165,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775845419261,
|
||||||
|
"tag": "0165_abnormal_greymalkin",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -67,6 +67,7 @@
|
|||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
"@radix-ui/react-collapsible": "^1.1.11",
|
"@radix-ui/react-collapsible": "^1.1.11",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { and, asc, eq } from "drizzle-orm";
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { organization, server, user } from "@/server/db/schema";
|
import { organization, server, user } from "@/server/db/schema";
|
||||||
|
import {
|
||||||
|
sendInvoiceEmail,
|
||||||
|
sendPaymentFailedEmail,
|
||||||
|
} from "@/server/utils/stripe-notifications";
|
||||||
|
|
||||||
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
|
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
|
||||||
|
|
||||||
@@ -241,6 +245,11 @@ export default async function handler(
|
|||||||
}
|
}
|
||||||
const newServersQuantity = admin.serversQuantity;
|
const newServersQuantity = admin.serversQuantity;
|
||||||
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
|
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
|
||||||
|
|
||||||
|
if (admin.sendInvoiceNotifications) {
|
||||||
|
await sendInvoiceEmail(newInvoice, admin);
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "invoice.payment_failed": {
|
case "invoice.payment_failed": {
|
||||||
@@ -249,7 +258,6 @@ export default async function handler(
|
|||||||
const subscription = await stripe.subscriptions.retrieve(
|
const subscription = await stripe.subscriptions.retrieve(
|
||||||
newInvoice.subscription as string,
|
newInvoice.subscription as string,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (subscription.status !== "active") {
|
if (subscription.status !== "active") {
|
||||||
const admin = await findUserByStripeCustomerId(
|
const admin = await findUserByStripeCustomerId(
|
||||||
newInvoice.customer as string,
|
newInvoice.customer as string,
|
||||||
@@ -263,6 +271,10 @@ export default async function handler(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (admin.sendInvoiceNotifications) {
|
||||||
|
await sendPaymentFailedEmail(newInvoice, admin);
|
||||||
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(user)
|
.update(user)
|
||||||
.set({
|
.set({
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function DeploymentsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-8xl mx-auto min-h-[45vh]">
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl min-h-[45vh]">
|
||||||
<div className="rounded-xl bg-background shadow-md h-full">
|
<div className="rounded-xl bg-background shadow-md h-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Play,
|
Play,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
@@ -68,6 +69,14 @@ import {
|
|||||||
CommandInput,
|
CommandInput,
|
||||||
CommandItem,
|
CommandItem,
|
||||||
} from "@/components/ui/command";
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from "@/components/ui/context-menu";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -424,6 +433,7 @@ const EnvironmentPage = (
|
|||||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||||
const [deleteVolumes, setDeleteVolumes] = useState(false);
|
const [deleteVolumes, setDeleteVolumes] = useState(false);
|
||||||
const [selectedServerId, setSelectedServerId] = useState<string>("all");
|
const [selectedServerId, setSelectedServerId] = useState<string>("all");
|
||||||
|
const [serviceToDelete, setServiceToDelete] = useState<Services | null>(null);
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
if (selectedServices.length === filteredServices.length) {
|
if (selectedServices.length === filteredServices.length) {
|
||||||
@@ -814,6 +824,110 @@ const EnvironmentPage = (
|
|||||||
setIsBulkActionLoading(false);
|
setIsBulkActionLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getServiceActions = (service: Services) => {
|
||||||
|
switch (service.type) {
|
||||||
|
case "application":
|
||||||
|
return applicationActions;
|
||||||
|
case "compose":
|
||||||
|
return composeActions;
|
||||||
|
case "postgres":
|
||||||
|
return postgresActions;
|
||||||
|
case "mysql":
|
||||||
|
return mysqlActions;
|
||||||
|
case "mariadb":
|
||||||
|
return mariadbActions;
|
||||||
|
case "redis":
|
||||||
|
return redisActions;
|
||||||
|
case "mongo":
|
||||||
|
return mongoActions;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getServiceIdKey = (service: Services) => {
|
||||||
|
switch (service.type) {
|
||||||
|
case "application":
|
||||||
|
return "applicationId";
|
||||||
|
case "compose":
|
||||||
|
return "composeId";
|
||||||
|
case "postgres":
|
||||||
|
return "postgresId";
|
||||||
|
case "mysql":
|
||||||
|
return "mysqlId";
|
||||||
|
case "mariadb":
|
||||||
|
return "mariadbId";
|
||||||
|
case "redis":
|
||||||
|
return "redisId";
|
||||||
|
case "mongo":
|
||||||
|
return "mongoId";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServiceAction = async (
|
||||||
|
service: Services,
|
||||||
|
action: "start" | "stop" | "deploy",
|
||||||
|
) => {
|
||||||
|
const actions = getServiceActions(service);
|
||||||
|
const idKey = getServiceIdKey(service);
|
||||||
|
if (!actions || !idKey) return;
|
||||||
|
|
||||||
|
const actionLabels = {
|
||||||
|
start: { loading: "Starting", success: "started", error: "starting" },
|
||||||
|
stop: { loading: "Stopping", success: "stopped", error: "stopping" },
|
||||||
|
deploy: {
|
||||||
|
loading: "Deploying",
|
||||||
|
success: "queued for deployment",
|
||||||
|
error: "deploying",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const labels = actionLabels[action];
|
||||||
|
|
||||||
|
toast.promise(
|
||||||
|
(async () => {
|
||||||
|
await actions[action].mutateAsync({
|
||||||
|
[idKey]: service.id,
|
||||||
|
} as any);
|
||||||
|
})(),
|
||||||
|
{
|
||||||
|
loading: `${labels.loading} ${service.name}...`,
|
||||||
|
success: () => {
|
||||||
|
utils.environment.one.invalidate({ environmentId });
|
||||||
|
return `${service.name} ${labels.success} successfully`;
|
||||||
|
},
|
||||||
|
error: (error) =>
|
||||||
|
`Error ${labels.error} ${service.name}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServiceDelete = async (service: Services) => {
|
||||||
|
const actions = getServiceActions(service);
|
||||||
|
const idKey = getServiceIdKey(service);
|
||||||
|
if (!actions || !idKey) return;
|
||||||
|
|
||||||
|
toast.promise(
|
||||||
|
(async () => {
|
||||||
|
await actions.delete.mutateAsync({
|
||||||
|
[idKey]: service.id,
|
||||||
|
} as any);
|
||||||
|
})(),
|
||||||
|
{
|
||||||
|
loading: `Deleting ${service.name}...`,
|
||||||
|
success: () => {
|
||||||
|
utils.environment.one.invalidate({ environmentId });
|
||||||
|
return `${service.name} deleted successfully`;
|
||||||
|
},
|
||||||
|
error: (error) =>
|
||||||
|
`Error deleting ${service.name}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setServiceToDelete(null);
|
||||||
|
};
|
||||||
|
|
||||||
// Get unique servers from services
|
// Get unique servers from services
|
||||||
const availableServers = useMemo(() => {
|
const availableServers = useMemo(() => {
|
||||||
if (!applications) return [];
|
if (!applications) return [];
|
||||||
@@ -1472,110 +1586,156 @@ const EnvironmentPage = (
|
|||||||
<div className="flex w-full flex-col gap-4">
|
<div className="flex w-full flex-col gap-4">
|
||||||
<div className="gap-5 pb-10 grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
<div className="gap-5 pb-10 grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
{filteredServices?.map((service) => (
|
{filteredServices?.map((service) => (
|
||||||
<Link
|
<ContextMenu key={service.id}>
|
||||||
key={service.id}
|
<ContextMenuTrigger asChild>
|
||||||
href={`/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}`}
|
<Link
|
||||||
className="block"
|
href={`/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}`}
|
||||||
>
|
className="block"
|
||||||
<Card className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border">
|
|
||||||
{service.serverId && (
|
|
||||||
<div className="absolute -left-1 -top-2">
|
|
||||||
<ServerIcon className="size-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="absolute -right-1 -top-2">
|
|
||||||
<StatusTooltip status={service.status} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute -left-3 -bottom-3 size-9 translate-y-1 rounded-full p-0 transition-all duration-200 z-10 bg-background border",
|
|
||||||
selectedServices.includes(service.id)
|
|
||||||
? "opacity-100 translate-y-0"
|
|
||||||
: "opacity-0 group-hover:translate-y-0 group-hover:opacity-100",
|
|
||||||
)}
|
|
||||||
onClick={(e) =>
|
|
||||||
handleServiceSelect(service.id, e)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="h-full w-full flex items-center justify-center">
|
<Card className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border">
|
||||||
<Checkbox
|
{service.serverId && (
|
||||||
checked={selectedServices.includes(
|
<div className="absolute -left-1 -top-2">
|
||||||
service.id,
|
<ServerIcon className="size-4 text-muted-foreground" />
|
||||||
)}
|
|
||||||
className="data-[state=checked]:bg-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center justify-between">
|
|
||||||
<div className="flex flex-row items-center gap-2 justify-between w-full">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span className="text-base flex items-center gap-2 font-medium leading-none flex-wrap">
|
|
||||||
{service.name}
|
|
||||||
</span>
|
|
||||||
{service.description && (
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">
|
|
||||||
{service.description}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="text-sm font-medium text-muted-foreground self-start">
|
|
||||||
{service.type === "postgres" && (
|
|
||||||
<PostgresqlIcon className="h-7 w-7" />
|
|
||||||
)}
|
|
||||||
{service.type === "redis" && (
|
|
||||||
<RedisIcon className="h-7 w-7" />
|
|
||||||
)}
|
|
||||||
{service.type === "mariadb" && (
|
|
||||||
<MariadbIcon className="h-7 w-7" />
|
|
||||||
)}
|
|
||||||
{service.type === "mongo" && (
|
|
||||||
<MongodbIcon className="h-7 w-7" />
|
|
||||||
)}
|
|
||||||
{service.type === "mysql" && (
|
|
||||||
<MysqlIcon className="h-7 w-7" />
|
|
||||||
)}
|
|
||||||
{service.type === "application" &&
|
|
||||||
(service.icon ? (
|
|
||||||
// biome-ignore lint/performance/noImgElement: application icon is data URL
|
|
||||||
<img
|
|
||||||
src={service.icon}
|
|
||||||
alt={service.name}
|
|
||||||
className="size-7 object-contain"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<GlobeIcon className="h-6 w-6" />
|
|
||||||
))}
|
|
||||||
{service.type === "compose" && (
|
|
||||||
<CircuitBoard className="h-6 w-6" />
|
|
||||||
)}
|
|
||||||
{service.type === "libsql" && (
|
|
||||||
<LibsqlIcon className="h-6 w-6" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardFooter className="mt-auto">
|
|
||||||
<div className="space-y-1 text-sm w-full">
|
|
||||||
{service.serverName && (
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
|
|
||||||
<ServerIcon className="size-3" />
|
|
||||||
<span className="truncate">
|
|
||||||
{service.serverName}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<DateTooltip date={service.createdAt}>
|
<div className="absolute -right-1 -top-2">
|
||||||
Created
|
<StatusTooltip status={service.status} />
|
||||||
</DateTooltip>
|
</div>
|
||||||
</div>
|
|
||||||
</CardFooter>
|
<div
|
||||||
</Card>
|
className={cn(
|
||||||
</Link>
|
"absolute -left-3 -bottom-3 size-9 translate-y-1 rounded-full p-0 transition-all duration-200 z-10 bg-background border",
|
||||||
|
selectedServices.includes(service.id)
|
||||||
|
? "opacity-100 translate-y-0"
|
||||||
|
: "opacity-0 group-hover:translate-y-0 group-hover:opacity-100",
|
||||||
|
)}
|
||||||
|
onClick={(e) =>
|
||||||
|
handleServiceSelect(service.id, e)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="h-full w-full flex items-center justify-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedServices.includes(
|
||||||
|
service.id,
|
||||||
|
)}
|
||||||
|
className="data-[state=checked]:bg-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-row items-center gap-2 justify-between w-full">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-base flex items-center gap-2 font-medium leading-none flex-wrap">
|
||||||
|
{service.name}
|
||||||
|
</span>
|
||||||
|
{service.description && (
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
{service.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-sm font-medium text-muted-foreground self-start">
|
||||||
|
{service.type === "postgres" && (
|
||||||
|
<PostgresqlIcon className="h-7 w-7" />
|
||||||
|
)}
|
||||||
|
{service.type === "redis" && (
|
||||||
|
<RedisIcon className="h-7 w-7" />
|
||||||
|
)}
|
||||||
|
{service.type === "mariadb" && (
|
||||||
|
<MariadbIcon className="h-7 w-7" />
|
||||||
|
)}
|
||||||
|
{service.type === "mongo" && (
|
||||||
|
<MongodbIcon className="h-7 w-7" />
|
||||||
|
)}
|
||||||
|
{service.type === "mysql" && (
|
||||||
|
<MysqlIcon className="h-7 w-7" />
|
||||||
|
)}
|
||||||
|
{service.type === "application" &&
|
||||||
|
(service.icon ? (
|
||||||
|
// biome-ignore lint/performance/noImgElement: application icon is data URL
|
||||||
|
<img
|
||||||
|
src={service.icon}
|
||||||
|
alt={service.name}
|
||||||
|
className="size-7 object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<GlobeIcon className="h-6 w-6" />
|
||||||
|
))}
|
||||||
|
{service.type === "compose" && (
|
||||||
|
<CircuitBoard className="h-6 w-6" />
|
||||||
|
)}
|
||||||
|
{service.type === "libsql" && (
|
||||||
|
<LibsqlIcon className="h-6 w-6" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter className="mt-auto">
|
||||||
|
<div className="space-y-1 text-sm w-full">
|
||||||
|
{service.serverName && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
|
||||||
|
<ServerIcon className="size-3" />
|
||||||
|
<span className="truncate">
|
||||||
|
{service.serverName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DateTooltip date={service.createdAt}>
|
||||||
|
Created
|
||||||
|
</DateTooltip>
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
{service.type !== "libsql" && (
|
||||||
|
<ContextMenuContent className="w-48">
|
||||||
|
<ContextMenuLabel className="truncate">
|
||||||
|
{service.name}
|
||||||
|
</ContextMenuLabel>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() =>
|
||||||
|
handleServiceAction(service, "start")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Play className="size-4" />
|
||||||
|
Start
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() =>
|
||||||
|
handleServiceAction(service, "deploy")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4" />
|
||||||
|
Deploy
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
className="flex items-center gap-2 text-orange-500 focus:text-orange-500"
|
||||||
|
onClick={() =>
|
||||||
|
handleServiceAction(service, "stop")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Ban className="size-4" />
|
||||||
|
Stop
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
className="flex items-center gap-2 text-red-500 focus:text-red-500"
|
||||||
|
onClick={() => setServiceToDelete(service)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
Delete
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1586,6 +1746,38 @@ const EnvironmentPage = (
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Single Service Delete Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={!!serviceToDelete}
|
||||||
|
onOpenChange={(open) => !open && setServiceToDelete(null)}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Service</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete{" "}
|
||||||
|
<span className="font-semibold">{serviceToDelete?.name}</span>?
|
||||||
|
This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setServiceToDelete(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (serviceToDelete) {
|
||||||
|
handleServiceDelete(serviceToDelete);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { ShowSchedules } from "@/components/dashboard/application/schedules/show
|
|||||||
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
|
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
|
||||||
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
||||||
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
|
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
|
||||||
|
import { ShowComposeContainers } from "@/components/dashboard/compose/containers/show-compose-containers";
|
||||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||||
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
|
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
|
||||||
import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
|
import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
|
||||||
@@ -60,6 +61,7 @@ type TabState =
|
|||||||
| "advanced"
|
| "advanced"
|
||||||
| "deployments"
|
| "deployments"
|
||||||
| "domains"
|
| "domains"
|
||||||
|
| "containers"
|
||||||
| "monitoring"
|
| "monitoring"
|
||||||
| "volumeBackups";
|
| "volumeBackups";
|
||||||
|
|
||||||
@@ -231,6 +233,9 @@ const Service = (
|
|||||||
Deployments
|
Deployments
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
|
{permissions?.service.read && (
|
||||||
|
<TabsTrigger value="containers">Containers</TabsTrigger>
|
||||||
|
)}
|
||||||
{permissions?.service.create && (
|
{permissions?.service.create && (
|
||||||
<TabsTrigger value="backups">Backups</TabsTrigger>
|
<TabsTrigger value="backups">Backups</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
@@ -298,6 +303,18 @@ const Service = (
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
{permissions?.service.read && (
|
||||||
|
<TabsContent value="containers">
|
||||||
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
|
<ShowComposeContainers
|
||||||
|
serverId={data?.serverId || undefined}
|
||||||
|
appName={data?.appName || ""}
|
||||||
|
appType={data?.composeType || "docker-compose"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
{permissions?.monitoring.read && (
|
{permissions?.monitoring.read && (
|
||||||
<TabsContent value="monitoring">
|
<TabsContent value="monitoring">
|
||||||
<div className="pt-2.5">
|
<div className="pt-2.5">
|
||||||
|
|||||||
@@ -82,6 +82,16 @@ export default function Home({ IS_CLOUD }: Props) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
const isEmailNotVerified =
|
||||||
|
error.code === "EMAIL_NOT_VERIFIED" ||
|
||||||
|
error.message?.toLowerCase().includes("email not verified");
|
||||||
|
if (isEmailNotVerified) {
|
||||||
|
const msg =
|
||||||
|
"Your email is not verified. We've sent a new verification link to your email.";
|
||||||
|
toast.info(msg);
|
||||||
|
setError(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
setError(error.message || "An error occurred while logging in");
|
setError(error.message || "An error occurred while logging in");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ import { findProjectById } from "@dokploy/server/services/project";
|
|||||||
import {
|
import {
|
||||||
getProviderHeaders,
|
getProviderHeaders,
|
||||||
getProviderName,
|
getProviderName,
|
||||||
|
selectAIProvider,
|
||||||
type Model,
|
type Model,
|
||||||
} from "@dokploy/server/utils/ai/select-ai-provider";
|
} from "@dokploy/server/utils/ai/select-ai-provider";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { generateText } from "ai";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { slugify } from "@/lib/slug";
|
import { slugify } from "@/lib/slug";
|
||||||
import {
|
import {
|
||||||
@@ -95,6 +97,30 @@ export const aiRouter = createTRPCRouter({
|
|||||||
owned_by: "perplexity",
|
owned_by: "perplexity",
|
||||||
},
|
},
|
||||||
] as Model[];
|
] as Model[];
|
||||||
|
case "zai":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "glm-5",
|
||||||
|
object: "model",
|
||||||
|
created: Date.now(),
|
||||||
|
owned_by: "zai",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "glm-4.7",
|
||||||
|
object: "model",
|
||||||
|
created: Date.now(),
|
||||||
|
owned_by: "zai",
|
||||||
|
},
|
||||||
|
] as Model[];
|
||||||
|
case "minimax":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "MiniMax-M2.7",
|
||||||
|
object: "model",
|
||||||
|
created: Date.now(),
|
||||||
|
owned_by: "minimax",
|
||||||
|
},
|
||||||
|
] as Model[];
|
||||||
default:
|
default:
|
||||||
if (!input.apiKey)
|
if (!input.apiKey)
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -174,6 +200,107 @@ export const aiRouter = createTRPCRouter({
|
|||||||
return await deleteAiSettings(input.aiId);
|
return await deleteAiSettings(input.aiId);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getEnabledProviders: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const settings = await getAiSettingsByOrganizationId(
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
);
|
||||||
|
return settings
|
||||||
|
.filter((s) => s.isEnabled)
|
||||||
|
.map((s) => ({ aiId: s.aiId, name: s.name, model: s.model }));
|
||||||
|
}),
|
||||||
|
|
||||||
|
analyzeLogs: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
aiId: z.string().min(1),
|
||||||
|
logs: z.string().min(1),
|
||||||
|
context: z.enum(["build", "runtime"]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const aiSettings = await getAiSettingById(input.aiId);
|
||||||
|
if (!aiSettings?.isEnabled) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "AI provider is not enabled",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aiSettings.organizationId !== ctx.session.activeOrganizationId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Access denied",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = selectAIProvider(aiSettings);
|
||||||
|
const model = provider(aiSettings.model);
|
||||||
|
|
||||||
|
const contextLabel =
|
||||||
|
input.context === "build" ? "build/deployment" : "runtime/container";
|
||||||
|
|
||||||
|
const result = await generateText({
|
||||||
|
model,
|
||||||
|
prompt: `You are a DevOps engineer analyzing ${contextLabel} logs. Analyze the following logs and provide:
|
||||||
|
|
||||||
|
1. **Summary**: A brief summary of what's happening
|
||||||
|
2. **Issues Found**: Any errors, warnings, or problems detected
|
||||||
|
3. **Root Cause**: The most likely root cause if there are errors
|
||||||
|
4. **Suggested Fix**: Actionable steps to resolve the issues
|
||||||
|
|
||||||
|
Be concise and practical. Focus on the most important issues. If the logs look healthy, say so briefly.
|
||||||
|
|
||||||
|
Logs:
|
||||||
|
${input.logs}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { analysis: result.text };
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: `Analysis failed: ${error}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
testConnection: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
apiUrl: z.string().min(1),
|
||||||
|
apiKey: z.string(),
|
||||||
|
model: z.string().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
const provider = selectAIProvider({
|
||||||
|
apiUrl: input.apiUrl,
|
||||||
|
apiKey: input.apiKey,
|
||||||
|
});
|
||||||
|
const model = provider(input.model);
|
||||||
|
const result = await generateText({
|
||||||
|
model,
|
||||||
|
prompt: "Reply with 'ok'",
|
||||||
|
});
|
||||||
|
if (!result.text) {
|
||||||
|
throw new Error("No response received from the model");
|
||||||
|
}
|
||||||
|
return { success: true, message: "Connection successful" };
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: `Connection failed: ${error}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
suggest: protectedProcedure
|
suggest: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
findEnvironmentById,
|
findEnvironmentById,
|
||||||
findGitProviderById,
|
findGitProviderById,
|
||||||
findProjectById,
|
findProjectById,
|
||||||
|
getAccessibleServerIds,
|
||||||
getApplicationStats,
|
getApplicationStats,
|
||||||
|
getContainerLogs,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
mechanizeDockerContainer,
|
mechanizeDockerContainer,
|
||||||
readConfig,
|
readConfig,
|
||||||
@@ -26,7 +28,6 @@ import {
|
|||||||
updateDeploymentStatus,
|
updateDeploymentStatus,
|
||||||
writeConfig,
|
writeConfig,
|
||||||
writeConfigRemote,
|
writeConfigRemote,
|
||||||
getAccessibleServerIds,
|
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import {
|
import {
|
||||||
@@ -1101,4 +1102,39 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
total: countResult[0]?.count ?? 0,
|
total: countResult[0]?.count ?? 0,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
readLogs: protectedProcedure
|
||||||
|
.input(
|
||||||
|
apiFindOneApplication.extend({
|
||||||
|
tail: z.number().int().min(1).max(10000).default(100),
|
||||||
|
since: z
|
||||||
|
.string()
|
||||||
|
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
|
||||||
|
.default("all"),
|
||||||
|
search: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
await checkServiceAccess(ctx, input.applicationId, "read");
|
||||||
|
const application = await findApplicationById(input.applicationId);
|
||||||
|
if (
|
||||||
|
application.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this application",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await getContainerLogs(
|
||||||
|
application.appName,
|
||||||
|
input.tail,
|
||||||
|
input.since,
|
||||||
|
input.search,
|
||||||
|
application.serverId,
|
||||||
|
);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import {
|
|||||||
findGitProviderById,
|
findGitProviderById,
|
||||||
findProjectById,
|
findProjectById,
|
||||||
findServerById,
|
findServerById,
|
||||||
|
getAccessibleServerIds,
|
||||||
getComposeContainer,
|
getComposeContainer,
|
||||||
|
getContainerLogs,
|
||||||
getWebServerSettings,
|
getWebServerSettings,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
loadServices,
|
loadServices,
|
||||||
@@ -30,7 +32,6 @@ import {
|
|||||||
stopCompose,
|
stopCompose,
|
||||||
updateCompose,
|
updateCompose,
|
||||||
updateDeploymentStatus,
|
updateDeploymentStatus,
|
||||||
getAccessibleServerIds,
|
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import {
|
import {
|
||||||
@@ -1130,4 +1131,44 @@ export const composeRouter = createTRPCRouter({
|
|||||||
total: countResult[0]?.count ?? 0,
|
total: countResult[0]?.count ?? 0,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
readLogs: protectedProcedure
|
||||||
|
.input(
|
||||||
|
apiFindCompose.extend({
|
||||||
|
containerId: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.regex(/^[a-zA-Z0-9.\-_]+$/, "Invalid container id."),
|
||||||
|
tail: z.number().int().min(1).max(10000).default(100),
|
||||||
|
since: z
|
||||||
|
.string()
|
||||||
|
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
|
||||||
|
.default("all"),
|
||||||
|
search: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
await checkServiceAccess(ctx, input.composeId, "read");
|
||||||
|
const compose = await findComposeById(input.composeId);
|
||||||
|
if (
|
||||||
|
compose.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this compose",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await getContainerLogs(
|
||||||
|
input.containerId,
|
||||||
|
input.tail,
|
||||||
|
input.since,
|
||||||
|
input.search,
|
||||||
|
compose.serverId,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
|
containerKill,
|
||||||
containerRemove,
|
containerRemove,
|
||||||
containerRestart,
|
containerRestart,
|
||||||
|
containerStart,
|
||||||
|
containerStop,
|
||||||
findServerById,
|
findServerById,
|
||||||
getConfig,
|
getConfig,
|
||||||
getContainers,
|
getContainers,
|
||||||
@@ -35,24 +38,108 @@ export const dockerRouter = createTRPCRouter({
|
|||||||
return await getContainers(input.serverId);
|
return await getContainers(input.serverId);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
restartContainer: withPermission("docker", "read")
|
restartContainer: withPermission("service", "read")
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
containerId: z
|
containerId: z
|
||||||
.string()
|
.string()
|
||||||
.min(1)
|
.min(1)
|
||||||
.regex(containerIdRegex, "Invalid container id."),
|
.regex(containerIdRegex, "Invalid container id."),
|
||||||
|
serverId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const result = await containerRestart(input.containerId);
|
if (input.serverId) {
|
||||||
|
const server = await findServerById(input.serverId);
|
||||||
|
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await containerRestart(input.containerId, input.serverId);
|
||||||
await audit(ctx, {
|
await audit(ctx, {
|
||||||
action: "start",
|
action: "start",
|
||||||
resourceType: "docker",
|
resourceType: "docker",
|
||||||
resourceId: input.containerId,
|
resourceId: input.containerId,
|
||||||
resourceName: input.containerId,
|
resourceName: input.containerId,
|
||||||
});
|
});
|
||||||
return result;
|
}),
|
||||||
|
|
||||||
|
startContainer: withPermission("service", "read")
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
containerId: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.regex(containerIdRegex, "Invalid container id."),
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
if (input.serverId) {
|
||||||
|
const server = await findServerById(input.serverId);
|
||||||
|
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await containerStart(input.containerId, input.serverId);
|
||||||
|
await audit(ctx, {
|
||||||
|
action: "start",
|
||||||
|
resourceType: "docker",
|
||||||
|
resourceId: input.containerId,
|
||||||
|
resourceName: input.containerId,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
stopContainer: withPermission("service", "read")
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
containerId: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.regex(containerIdRegex, "Invalid container id."),
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
if (input.serverId) {
|
||||||
|
const server = await findServerById(input.serverId);
|
||||||
|
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await containerStop(input.containerId, input.serverId);
|
||||||
|
await audit(ctx, {
|
||||||
|
action: "stop",
|
||||||
|
resourceType: "docker",
|
||||||
|
resourceId: input.containerId,
|
||||||
|
resourceName: input.containerId,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
killContainer: withPermission("service", "read")
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
containerId: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.regex(containerIdRegex, "Invalid container id."),
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
if (input.serverId) {
|
||||||
|
const server = await findServerById(input.serverId);
|
||||||
|
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||||
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await containerKill(input.containerId, input.serverId);
|
||||||
|
await audit(ctx, {
|
||||||
|
action: "stop",
|
||||||
|
resourceType: "docker",
|
||||||
|
resourceId: input.containerId,
|
||||||
|
resourceName: input.containerId,
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
removeContainer: withPermission("docker", "read")
|
removeContainer: withPermission("docker", "read")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
findEnvironmentById,
|
findEnvironmentById,
|
||||||
findLibsqlById,
|
findLibsqlById,
|
||||||
findProjectById,
|
findProjectById,
|
||||||
|
getContainerLogs,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
rebuildDatabase,
|
rebuildDatabase,
|
||||||
removeLibsqlById,
|
removeLibsqlById,
|
||||||
@@ -466,4 +467,39 @@ export const libsqlRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
readLogs: protectedProcedure
|
||||||
|
.input(
|
||||||
|
apiFindOneLibsql.extend({
|
||||||
|
tail: z.number().int().min(1).max(10000).default(100),
|
||||||
|
since: z
|
||||||
|
.string()
|
||||||
|
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
|
||||||
|
.default("all"),
|
||||||
|
search: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
await checkServiceAccess(ctx, input.libsqlId, "read");
|
||||||
|
const libsql = await findLibsqlById(input.libsqlId);
|
||||||
|
if (
|
||||||
|
libsql.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this LibSQL",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await getContainerLogs(
|
||||||
|
libsql.appName,
|
||||||
|
input.tail,
|
||||||
|
input.since,
|
||||||
|
input.search,
|
||||||
|
libsql.serverId,
|
||||||
|
);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
findEnvironmentById,
|
findEnvironmentById,
|
||||||
findMariadbById,
|
findMariadbById,
|
||||||
findProjectById,
|
findProjectById,
|
||||||
|
getAccessibleServerIds,
|
||||||
|
getContainerLogs,
|
||||||
getServiceContainerCommand,
|
getServiceContainerCommand,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
rebuildDatabase,
|
rebuildDatabase,
|
||||||
@@ -19,7 +21,6 @@ import {
|
|||||||
stopService,
|
stopService,
|
||||||
stopServiceRemote,
|
stopServiceRemote,
|
||||||
updateMariadbById,
|
updateMariadbById,
|
||||||
getAccessibleServerIds,
|
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import {
|
import {
|
||||||
@@ -590,4 +591,39 @@ export const mariadbRouter = createTRPCRouter({
|
|||||||
]);
|
]);
|
||||||
return { items, total: countResult[0]?.count ?? 0 };
|
return { items, total: countResult[0]?.count ?? 0 };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
readLogs: protectedProcedure
|
||||||
|
.input(
|
||||||
|
apiFindOneMariaDB.extend({
|
||||||
|
tail: z.number().int().min(1).max(10000).default(100),
|
||||||
|
since: z
|
||||||
|
.string()
|
||||||
|
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
|
||||||
|
.default("all"),
|
||||||
|
search: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
await checkServiceAccess(ctx, input.mariadbId, "read");
|
||||||
|
const mariadb = await findMariadbById(input.mariadbId);
|
||||||
|
if (
|
||||||
|
mariadb.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this MariaDB",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await getContainerLogs(
|
||||||
|
mariadb.appName,
|
||||||
|
input.tail,
|
||||||
|
input.since,
|
||||||
|
input.search,
|
||||||
|
mariadb.serverId,
|
||||||
|
);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
findMongoById,
|
findMongoById,
|
||||||
findProjectById,
|
findProjectById,
|
||||||
getAccessibleServerIds,
|
getAccessibleServerIds,
|
||||||
|
getContainerLogs,
|
||||||
getServiceContainerCommand,
|
getServiceContainerCommand,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
rebuildDatabase,
|
rebuildDatabase,
|
||||||
@@ -601,4 +602,39 @@ export const mongoRouter = createTRPCRouter({
|
|||||||
]);
|
]);
|
||||||
return { items, total: countResult[0]?.count ?? 0 };
|
return { items, total: countResult[0]?.count ?? 0 };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
readLogs: protectedProcedure
|
||||||
|
.input(
|
||||||
|
apiFindOneMongo.extend({
|
||||||
|
tail: z.number().int().min(1).max(10000).default(100),
|
||||||
|
since: z
|
||||||
|
.string()
|
||||||
|
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
|
||||||
|
.default("all"),
|
||||||
|
search: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
await checkServiceAccess(ctx, input.mongoId, "read");
|
||||||
|
const mongo = await findMongoById(input.mongoId);
|
||||||
|
if (
|
||||||
|
mongo.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this MongoDB",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await getContainerLogs(
|
||||||
|
mongo.appName,
|
||||||
|
input.tail,
|
||||||
|
input.since,
|
||||||
|
input.search,
|
||||||
|
mongo.serverId,
|
||||||
|
);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
findEnvironmentById,
|
findEnvironmentById,
|
||||||
findMySqlById,
|
findMySqlById,
|
||||||
findProjectById,
|
findProjectById,
|
||||||
|
getContainerLogs,
|
||||||
getServiceContainerCommand,
|
getServiceContainerCommand,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
rebuildDatabase,
|
rebuildDatabase,
|
||||||
@@ -604,4 +605,39 @@ export const mysqlRouter = createTRPCRouter({
|
|||||||
]);
|
]);
|
||||||
return { items, total: countResult[0]?.count ?? 0 };
|
return { items, total: countResult[0]?.count ?? 0 };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
readLogs: protectedProcedure
|
||||||
|
.input(
|
||||||
|
apiFindOneMySql.extend({
|
||||||
|
tail: z.number().int().min(1).max(10000).default(100),
|
||||||
|
since: z
|
||||||
|
.string()
|
||||||
|
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
|
||||||
|
.default("all"),
|
||||||
|
search: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
await checkServiceAccess(ctx, input.mysqlId, "read");
|
||||||
|
const mysql = await findMySqlById(input.mysqlId);
|
||||||
|
if (
|
||||||
|
mysql.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this MySQL",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await getContainerLogs(
|
||||||
|
mysql.appName,
|
||||||
|
input.tail,
|
||||||
|
input.since,
|
||||||
|
input.search,
|
||||||
|
mysql.serverId,
|
||||||
|
);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
findEnvironmentById,
|
findEnvironmentById,
|
||||||
findPostgresById,
|
findPostgresById,
|
||||||
findProjectById,
|
findProjectById,
|
||||||
|
getContainerLogs,
|
||||||
getMountPath,
|
getMountPath,
|
||||||
getServiceContainerCommand,
|
getServiceContainerCommand,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
@@ -614,4 +615,39 @@ export const postgresRouter = createTRPCRouter({
|
|||||||
]);
|
]);
|
||||||
return { items, total: countResult[0]?.count ?? 0 };
|
return { items, total: countResult[0]?.count ?? 0 };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
readLogs: protectedProcedure
|
||||||
|
.input(
|
||||||
|
apiFindOnePostgres.extend({
|
||||||
|
tail: z.number().int().min(1).max(10000).default(100),
|
||||||
|
since: z
|
||||||
|
.string()
|
||||||
|
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
|
||||||
|
.default("all"),
|
||||||
|
search: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
await checkServiceAccess(ctx, input.postgresId, "read");
|
||||||
|
const postgres = await findPostgresById(input.postgresId);
|
||||||
|
if (
|
||||||
|
postgres.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this Postgres",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await getContainerLogs(
|
||||||
|
postgres.appName,
|
||||||
|
input.tail,
|
||||||
|
input.since,
|
||||||
|
input.search,
|
||||||
|
postgres.serverId,
|
||||||
|
);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
findEnvironmentById,
|
findEnvironmentById,
|
||||||
findProjectById,
|
findProjectById,
|
||||||
findRedisById,
|
findRedisById,
|
||||||
|
getContainerLogs,
|
||||||
getServiceContainerCommand,
|
getServiceContainerCommand,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
rebuildDatabase,
|
rebuildDatabase,
|
||||||
@@ -587,4 +588,39 @@ export const redisRouter = createTRPCRouter({
|
|||||||
]);
|
]);
|
||||||
return { items, total: countResult[0]?.count ?? 0 };
|
return { items, total: countResult[0]?.count ?? 0 };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
readLogs: protectedProcedure
|
||||||
|
.input(
|
||||||
|
apiFindOneRedis.extend({
|
||||||
|
tail: z.number().int().min(1).max(10000).default(100),
|
||||||
|
since: z
|
||||||
|
.string()
|
||||||
|
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
|
||||||
|
.default("all"),
|
||||||
|
search: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
await checkServiceAccess(ctx, input.redisId, "read");
|
||||||
|
const redis = await findRedisById(input.redisId);
|
||||||
|
if (
|
||||||
|
redis.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to access this Redis",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await getContainerLogs(
|
||||||
|
redis.appName,
|
||||||
|
input.tail,
|
||||||
|
input.since,
|
||||||
|
input.search,
|
||||||
|
redis.serverId,
|
||||||
|
);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import {
|
|||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { createTRPCRouter, withPermission } from "@/server/api/trpc";
|
import {
|
||||||
|
createTRPCRouter,
|
||||||
|
protectedProcedure,
|
||||||
|
withPermission,
|
||||||
|
} from "@/server/api/trpc";
|
||||||
import { audit } from "@/server/api/utils/audit";
|
import { audit } from "@/server/api/utils/audit";
|
||||||
import {
|
import {
|
||||||
apiCreateSshKey,
|
apiCreateSshKey,
|
||||||
@@ -83,6 +87,16 @@ export const sshRouter = createTRPCRouter({
|
|||||||
orderBy: desc(sshKeys.createdAt),
|
orderBy: desc(sshKeys.createdAt),
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
allForApps: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
return await db.query.sshKeys.findMany({
|
||||||
|
columns: {
|
||||||
|
sshKeyId: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
where: eq(sshKeys.organizationId, ctx.session.activeOrganizationId),
|
||||||
|
orderBy: desc(sshKeys.createdAt),
|
||||||
|
});
|
||||||
|
}),
|
||||||
generate: withPermission("sshKeys", "read")
|
generate: withPermission("sshKeys", "read")
|
||||||
.input(apiGenerateSSHKey)
|
.input(apiGenerateSSHKey)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
|
|||||||
@@ -205,11 +205,16 @@ export const stripeRouter = createTRPCRouter({
|
|||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
line_items: items,
|
line_items: items,
|
||||||
...(stripeCustomerId
|
...(stripeCustomerId
|
||||||
? { customer: stripeCustomerId }
|
? {
|
||||||
|
customer: stripeCustomerId,
|
||||||
|
customer_update: { name: "auto", address: "auto" },
|
||||||
|
}
|
||||||
: { customer_email: owner.email }),
|
: { customer_email: owner.email }),
|
||||||
metadata: {
|
metadata: {
|
||||||
adminId: owner.id,
|
adminId: owner.id,
|
||||||
},
|
},
|
||||||
|
billing_address_collection: "required",
|
||||||
|
tax_id_collection: { enabled: true },
|
||||||
allow_promotion_codes: true,
|
allow_promotion_codes: true,
|
||||||
success_url: `${WEBSITE_URL}/dashboard/settings/servers?success=true`,
|
success_url: `${WEBSITE_URL}/dashboard/settings/servers?success=true`,
|
||||||
cancel_url: `${WEBSITE_URL}/dashboard/settings/billing`,
|
cancel_url: `${WEBSITE_URL}/dashboard/settings/billing`,
|
||||||
@@ -332,6 +337,22 @@ export const stripeRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
|
updateInvoiceNotifications: adminProcedure
|
||||||
|
.input(z.object({ enabled: z.boolean() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
if (!IS_CLOUD) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "This feature is only available in Dokploy Cloud",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const owner = await findUserById(ctx.user.ownerId);
|
||||||
|
await updateUser(owner.id, {
|
||||||
|
sendInvoiceNotifications: input.enabled,
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
}),
|
||||||
|
|
||||||
getInvoices: adminProcedure.query(async ({ ctx }) => {
|
getInvoices: adminProcedure.query(async ({ ctx }) => {
|
||||||
const user = await findUserById(ctx.user.ownerId);
|
const user = await findUserById(ctx.user.ownerId);
|
||||||
const stripeCustomerId = user.stripeCustomerId;
|
const stripeCustomerId = user.stripeCustomerId;
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ void app.prepare().then(async () => {
|
|||||||
setupDockerStatsMonitoringSocketServer(server);
|
setupDockerStatsMonitoringSocketServer(server);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
server.listen(PORT, HOST);
|
||||||
|
console.log(`Server Started on: http://${HOST}:${PORT}`);
|
||||||
if (process.env.NODE_ENV === "production" && !IS_CLOUD) {
|
if (process.env.NODE_ENV === "production" && !IS_CLOUD) {
|
||||||
createDefaultMiddlewares();
|
createDefaultMiddlewares();
|
||||||
await initializeNetwork();
|
await initializeNetwork();
|
||||||
@@ -65,9 +67,6 @@ void app.prepare().then(async () => {
|
|||||||
await initVolumeBackupsCronJobs();
|
await initVolumeBackupsCronJobs();
|
||||||
await sendDokployRestartNotifications();
|
await sendDokployRestartNotifications();
|
||||||
}
|
}
|
||||||
|
|
||||||
server.listen(PORT, HOST);
|
|
||||||
console.log(`Server Started on: http://${HOST}:${PORT}`);
|
|
||||||
await initEnterpriseBackupCronJobs();
|
await initEnterpriseBackupCronJobs();
|
||||||
|
|
||||||
if (!IS_CLOUD) {
|
if (!IS_CLOUD) {
|
||||||
|
|||||||
113
apps/dokploy/server/utils/stripe-notifications.ts
Normal file
113
apps/dokploy/server/utils/stripe-notifications.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import InvoiceNotificationEmail from "@dokploy/server/emails/emails/invoice-notification";
|
||||||
|
import PaymentFailedEmail from "@dokploy/server/emails/emails/payment-failed";
|
||||||
|
import { sendEmail } from "@dokploy/server/verification/send-verification-email";
|
||||||
|
import { renderAsync } from "@react-email/components";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
|
||||||
|
function formatAmount(amountInCents: number, currency: string): string {
|
||||||
|
const amount = amountInCents / 100;
|
||||||
|
const formatter = new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: currency.toUpperCase(),
|
||||||
|
});
|
||||||
|
return formatter.format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadPdf = async (url: string): Promise<Buffer | null> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
return Buffer.from(arrayBuffer);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendInvoiceEmail = async (
|
||||||
|
invoice: Stripe.Invoice,
|
||||||
|
admin: { email: string; firstName: string },
|
||||||
|
) => {
|
||||||
|
if (!invoice.hosted_invoice_url) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const amountFormatted = formatAmount(invoice.amount_paid, invoice.currency);
|
||||||
|
|
||||||
|
const htmlContent = await renderAsync(
|
||||||
|
InvoiceNotificationEmail({
|
||||||
|
userName: admin.firstName || "User",
|
||||||
|
invoiceNumber: invoice.number || invoice.id,
|
||||||
|
amountPaid: amountFormatted,
|
||||||
|
currency: invoice.currency,
|
||||||
|
date: format(new Date(invoice.created * 1000), "MMM dd, yyyy"),
|
||||||
|
hostedInvoiceUrl: invoice.hosted_invoice_url,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const attachments: { filename: string; content: Buffer }[] = [];
|
||||||
|
|
||||||
|
if (invoice.invoice_pdf) {
|
||||||
|
const pdfBuffer = await downloadPdf(invoice.invoice_pdf);
|
||||||
|
if (pdfBuffer) {
|
||||||
|
attachments.push({
|
||||||
|
filename: `dokploy-invoice-${invoice.number || invoice.id}.pdf`,
|
||||||
|
content: pdfBuffer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
email: admin.email,
|
||||||
|
subject: `Dokploy Invoice ${invoice.number || ""} - ${amountFormatted}`,
|
||||||
|
text: htmlContent,
|
||||||
|
attachments,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Invoice email sent to ${admin.email} for invoice ${invoice.number}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Failed to send invoice email to ${admin.email}:`,
|
||||||
|
error instanceof Error ? error.message : error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendPaymentFailedEmail = async (
|
||||||
|
invoice: Stripe.Invoice,
|
||||||
|
admin: { email: string; firstName: string },
|
||||||
|
) => {
|
||||||
|
if (!invoice.hosted_invoice_url) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const amountFormatted = formatAmount(invoice.amount_due, invoice.currency);
|
||||||
|
|
||||||
|
const htmlContent = await renderAsync(
|
||||||
|
PaymentFailedEmail({
|
||||||
|
userName: admin.firstName || "User",
|
||||||
|
invoiceNumber: invoice.number || invoice.id,
|
||||||
|
amountDue: amountFormatted,
|
||||||
|
currency: invoice.currency,
|
||||||
|
date: format(new Date(invoice.created * 1000), "MMM dd, yyyy"),
|
||||||
|
hostedInvoiceUrl: invoice.hosted_invoice_url,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
email: admin.email,
|
||||||
|
subject: `Action required: Dokploy payment failed - ${amountFormatted}`,
|
||||||
|
text: htmlContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Payment failed email sent to ${admin.email} for invoice ${invoice.number}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Failed to send payment failed email to ${admin.email}:`,
|
||||||
|
error instanceof Error ? error.message : error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -33,7 +33,7 @@ app.use(async (c, next) => {
|
|||||||
|
|
||||||
app.post("/create-backup", zValidator("json", jobQueueSchema), async (c) => {
|
app.post("/create-backup", zValidator("json", jobQueueSchema), async (c) => {
|
||||||
const data = c.req.valid("json");
|
const data = c.req.valid("json");
|
||||||
scheduleJob(data);
|
await scheduleJob(data);
|
||||||
logger.info({ data }, `[${data.type}] created successfully`);
|
logger.info({ data }, `[${data.type}] created successfully`);
|
||||||
return c.json({ message: `[${data.type}] created successfully` });
|
return c.json({ message: `[${data.type}] created successfully` });
|
||||||
});
|
});
|
||||||
@@ -70,7 +70,7 @@ app.post("/update-backup", zValidator("json", jobQueueSchema), async (c) => {
|
|||||||
}
|
}
|
||||||
logger.info({ result }, "Job removed");
|
logger.info({ result }, "Job removed");
|
||||||
}
|
}
|
||||||
scheduleJob(data);
|
await scheduleJob(data);
|
||||||
logger.info("Backup updated successfully");
|
logger.info("Backup updated successfully");
|
||||||
|
|
||||||
return c.json({ message: "Backup updated successfully" });
|
return c.json({ message: "Backup updated successfully" });
|
||||||
@@ -103,8 +103,11 @@ process.on("uncaughtException", (err) => {
|
|||||||
logger.error(err, "Uncaught exception");
|
logger.error(err, "Uncaught exception");
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on("unhandledRejection", (reason, promise) => {
|
process.on("unhandledRejection", (reason, _promise) => {
|
||||||
logger.error({ promise, reason }, "Unhandled Rejection at: Promise");
|
logger.error(
|
||||||
|
reason instanceof Error ? reason : { reason: String(reason) },
|
||||||
|
"Unhandled Rejection at: Promise",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const port = Number.parseInt(process.env.PORT || "3000");
|
const port = Number.parseInt(process.env.PORT || "3000");
|
||||||
|
|||||||
@@ -21,28 +21,28 @@ export const cleanQueue = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const scheduleJob = (job: QueueJob) => {
|
export const scheduleJob = async (job: QueueJob) => {
|
||||||
if (job.type === "backup") {
|
if (job.type === "backup") {
|
||||||
jobQueue.add(job.backupId, job, {
|
await jobQueue.add(job.backupId, job, {
|
||||||
repeat: {
|
repeat: {
|
||||||
pattern: job.cronSchedule,
|
pattern: job.cronSchedule,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else if (job.type === "server") {
|
} else if (job.type === "server") {
|
||||||
jobQueue.add(`${job.serverId}-cleanup`, job, {
|
await jobQueue.add(`${job.serverId}-cleanup`, job, {
|
||||||
repeat: {
|
repeat: {
|
||||||
pattern: job.cronSchedule,
|
pattern: job.cronSchedule,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else if (job.type === "schedule") {
|
} else if (job.type === "schedule") {
|
||||||
jobQueue.add(job.scheduleId, job, {
|
await jobQueue.add(job.scheduleId, job, {
|
||||||
repeat: {
|
repeat: {
|
||||||
pattern: job.cronSchedule,
|
pattern: job.cronSchedule,
|
||||||
tz: job.timezone || "UTC",
|
tz: job.timezone || "UTC",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else if (job.type === "volume-backup") {
|
} else if (job.type === "volume-backup") {
|
||||||
jobQueue.add(job.volumeBackupId, job, {
|
await jobQueue.add(job.volumeBackupId, job, {
|
||||||
repeat: {
|
repeat: {
|
||||||
pattern: job.cronSchedule,
|
pattern: job.cronSchedule,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -135,11 +135,18 @@ export const initializeJobs = async () => {
|
|||||||
|
|
||||||
for (const server of servers) {
|
for (const server of servers) {
|
||||||
const { serverId } = server;
|
const { serverId } = server;
|
||||||
scheduleJob({
|
try {
|
||||||
serverId,
|
await scheduleJob({
|
||||||
type: "server",
|
serverId,
|
||||||
cronSchedule: CLEANUP_CRON_JOB,
|
type: "server",
|
||||||
});
|
cronSchedule: CLEANUP_CRON_JOB,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
error,
|
||||||
|
`Failed to schedule cleanup job for server ${serverId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info({ Quantity: servers.length }, "Active Servers Initialized");
|
logger.info({ Quantity: servers.length }, "Active Servers Initialized");
|
||||||
@@ -157,11 +164,15 @@ export const initializeJobs = async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const backup of backupsResult) {
|
for (const backup of backupsResult) {
|
||||||
scheduleJob({
|
try {
|
||||||
backupId: backup.backupId,
|
await scheduleJob({
|
||||||
type: "backup",
|
backupId: backup.backupId,
|
||||||
cronSchedule: backup.schedule,
|
type: "backup",
|
||||||
});
|
cronSchedule: backup.schedule,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error, `Failed to schedule backup ${backup.backupId}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
logger.info({ Quantity: backupsResult.length }, "Backups Initialized");
|
logger.info({ Quantity: backupsResult.length }, "Backups Initialized");
|
||||||
|
|
||||||
@@ -197,11 +208,15 @@ export const initializeJobs = async () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const schedule of filteredSchedulesBasedOnServerStatus) {
|
for (const schedule of filteredSchedulesBasedOnServerStatus) {
|
||||||
scheduleJob({
|
try {
|
||||||
scheduleId: schedule.scheduleId,
|
await scheduleJob({
|
||||||
type: "schedule",
|
scheduleId: schedule.scheduleId,
|
||||||
cronSchedule: schedule.cronExpression,
|
type: "schedule",
|
||||||
});
|
cronSchedule: schedule.cronExpression,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error, `Failed to schedule ${schedule.scheduleId}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
logger.info(
|
logger.info(
|
||||||
{ Quantity: filteredSchedulesBasedOnServerStatus.length },
|
{ Quantity: filteredSchedulesBasedOnServerStatus.length },
|
||||||
@@ -236,11 +251,18 @@ export const initializeJobs = async () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const volumeBackup of filteredVolumeBackupsBasedOnServerStatus) {
|
for (const volumeBackup of filteredVolumeBackupsBasedOnServerStatus) {
|
||||||
scheduleJob({
|
try {
|
||||||
volumeBackupId: volumeBackup.volumeBackupId,
|
await scheduleJob({
|
||||||
type: "volume-backup",
|
volumeBackupId: volumeBackup.volumeBackupId,
|
||||||
cronSchedule: volumeBackup.cronExpression,
|
type: "volume-backup",
|
||||||
});
|
cronSchedule: volumeBackup.cronExpression,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
error,
|
||||||
|
`Failed to schedule volume backup ${volumeBackup.volumeBackupId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ export const user = pgTable("user", {
|
|||||||
stripeCustomerId: text("stripeCustomerId"),
|
stripeCustomerId: text("stripeCustomerId"),
|
||||||
stripeSubscriptionId: text("stripeSubscriptionId"),
|
stripeSubscriptionId: text("stripeSubscriptionId"),
|
||||||
serversQuantity: integer("serversQuantity").notNull().default(0),
|
serversQuantity: integer("serversQuantity").notNull().default(0),
|
||||||
|
sendInvoiceNotifications: boolean("sendInvoiceNotifications")
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
isEnterpriseCloud: boolean("isEnterpriseCloud").notNull().default(false),
|
isEnterpriseCloud: boolean("isEnterpriseCloud").notNull().default(false),
|
||||||
trustedOrigins: text("trustedOrigins").array(),
|
trustedOrigins: text("trustedOrigins").array(),
|
||||||
bookmarkedTemplates: text("bookmarkedTemplates")
|
bookmarkedTemplates: text("bookmarkedTemplates")
|
||||||
|
|||||||
171
packages/server/src/emails/emails/invoice-notification.tsx
Normal file
171
packages/server/src/emails/emails/invoice-notification.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Button,
|
||||||
|
Column,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Hr,
|
||||||
|
Html,
|
||||||
|
Img,
|
||||||
|
Link,
|
||||||
|
Preview,
|
||||||
|
Row,
|
||||||
|
Section,
|
||||||
|
Tailwind,
|
||||||
|
Text,
|
||||||
|
} from "@react-email/components";
|
||||||
|
|
||||||
|
export type TemplateProps = {
|
||||||
|
userName: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
amountPaid: string;
|
||||||
|
currency: string;
|
||||||
|
date: string;
|
||||||
|
hostedInvoiceUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InvoiceNotificationEmail = ({
|
||||||
|
userName = "User",
|
||||||
|
invoiceNumber = "INV-0001",
|
||||||
|
amountPaid = "$4.50",
|
||||||
|
currency = "usd",
|
||||||
|
date = "2024-01-01",
|
||||||
|
hostedInvoiceUrl = "https://invoice.stripe.com/example",
|
||||||
|
}: TemplateProps) => {
|
||||||
|
const previewText = `Your Dokploy invoice ${invoiceNumber} for ${amountPaid} is ready`;
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<Tailwind
|
||||||
|
config={{
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: "#007291",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
|
||||||
|
<Container className="my-[40px] mx-auto max-w-[520px]">
|
||||||
|
{/* Header */}
|
||||||
|
<Section className="bg-[#09090b] rounded-t-xl px-[40px] py-[32px] text-center">
|
||||||
|
<Img
|
||||||
|
src="https://raw.githubusercontent.com/Dokploy/website/refs/heads/main/apps/docs/public/logo-dokploy-blackpng.png"
|
||||||
|
width="190"
|
||||||
|
height="120"
|
||||||
|
alt="Dokploy"
|
||||||
|
className="my-0 mx-auto"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<Section className="bg-white px-[40px] py-[32px]">
|
||||||
|
<Heading className="text-[#09090b] text-[22px] font-semibold m-0 mb-[8px]">
|
||||||
|
Invoice Payment Confirmed
|
||||||
|
</Heading>
|
||||||
|
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
|
||||||
|
Hello {userName}, thank you for your payment. Here's a summary
|
||||||
|
of your invoice.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Invoice Details Card */}
|
||||||
|
<Section className="border border-solid border-[#e4e4e7] rounded-lg overflow-hidden mb-[24px]">
|
||||||
|
<Row className="bg-[#fafafa]">
|
||||||
|
<Column className="px-[20px] py-[14px] w-[50%]">
|
||||||
|
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
|
||||||
|
Invoice No.
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[#09090b] text-[14px] font-semibold m-0 mt-[4px]">
|
||||||
|
{invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
</Column>
|
||||||
|
<Column className="px-[20px] py-[14px] w-[50%]">
|
||||||
|
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
|
||||||
|
Date
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[#09090b] text-[14px] font-semibold m-0 mt-[4px]">
|
||||||
|
{date}
|
||||||
|
</Text>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
<Hr className="border-[#e4e4e7] m-0" />
|
||||||
|
<Row>
|
||||||
|
<Column className="px-[20px] py-[14px]">
|
||||||
|
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
|
||||||
|
Amount Paid
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[#09090b] text-[20px] font-bold m-0 mt-[4px]">
|
||||||
|
{amountPaid}{" "}
|
||||||
|
<span className="text-[#71717a] text-[12px] font-normal uppercase">
|
||||||
|
{currency}
|
||||||
|
</span>
|
||||||
|
</Text>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Status Badge */}
|
||||||
|
<Section className="mb-[24px]">
|
||||||
|
<Row>
|
||||||
|
<Column>
|
||||||
|
<div
|
||||||
|
className="inline-block rounded-full bg-[#dcfce7] px-[12px] py-[6px]"
|
||||||
|
style={{ display: "inline-block" }}
|
||||||
|
>
|
||||||
|
<Text className="text-[#15803d] text-[12px] font-semibold m-0">
|
||||||
|
Payment Successful
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* CTA Button */}
|
||||||
|
<Section className="text-center mb-[24px]">
|
||||||
|
<Button
|
||||||
|
href={hostedInvoiceUrl}
|
||||||
|
className="bg-[#09090b] rounded-lg text-white text-[14px] font-semibold no-underline text-center px-[24px] py-[12px]"
|
||||||
|
>
|
||||||
|
View Invoice Online
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Text className="text-[#a1a1aa] text-[13px] leading-[20px] m-0 text-center">
|
||||||
|
A PDF copy of this invoice is attached to this email for your
|
||||||
|
records.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Section className="bg-[#fafafa] rounded-b-xl px-[40px] py-[24px] text-center border-t border-solid border-[#e4e4e7]">
|
||||||
|
<Text className="text-[#a1a1aa] text-[12px] leading-[18px] m-0">
|
||||||
|
This is an automated email from{" "}
|
||||||
|
<Link
|
||||||
|
href="https://dokploy.com"
|
||||||
|
className="text-[#71717a] underline"
|
||||||
|
>
|
||||||
|
Dokploy Cloud
|
||||||
|
</Link>
|
||||||
|
. If you have any questions about your billing, please contact
|
||||||
|
our{" "}
|
||||||
|
<Link
|
||||||
|
href="https://discord.gg/2tBnJ3jDJc"
|
||||||
|
className="text-[#71717a] underline"
|
||||||
|
>
|
||||||
|
support team
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InvoiceNotificationEmail;
|
||||||
175
packages/server/src/emails/emails/payment-failed.tsx
Normal file
175
packages/server/src/emails/emails/payment-failed.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Button,
|
||||||
|
Column,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Hr,
|
||||||
|
Html,
|
||||||
|
Img,
|
||||||
|
Link,
|
||||||
|
Preview,
|
||||||
|
Row,
|
||||||
|
Section,
|
||||||
|
Tailwind,
|
||||||
|
Text,
|
||||||
|
} from "@react-email/components";
|
||||||
|
|
||||||
|
export type TemplateProps = {
|
||||||
|
userName: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
amountDue: string;
|
||||||
|
currency: string;
|
||||||
|
date: string;
|
||||||
|
hostedInvoiceUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PaymentFailedEmail = ({
|
||||||
|
userName = "User",
|
||||||
|
invoiceNumber = "INV-0001",
|
||||||
|
amountDue = "$4.50",
|
||||||
|
currency = "usd",
|
||||||
|
date = "2024-01-01",
|
||||||
|
hostedInvoiceUrl = "https://invoice.stripe.com/example",
|
||||||
|
}: TemplateProps) => {
|
||||||
|
const previewText = `Action required: Your Dokploy payment for ${amountDue} failed`;
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<Tailwind
|
||||||
|
config={{
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: "#007291",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
|
||||||
|
<Container className="my-[40px] mx-auto max-w-[520px]">
|
||||||
|
{/* Header */}
|
||||||
|
<Section className="bg-[#09090b] rounded-t-xl px-[40px] py-[32px] text-center">
|
||||||
|
<Img
|
||||||
|
src="https://raw.githubusercontent.com/Dokploy/website/refs/heads/main/apps/docs/public/logo-dokploy-blackpng.png"
|
||||||
|
width="190"
|
||||||
|
height="120"
|
||||||
|
alt="Dokploy"
|
||||||
|
className="my-0 mx-auto"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<Section className="bg-white px-[40px] py-[32px]">
|
||||||
|
<Heading className="text-[#09090b] text-[22px] font-semibold m-0 mb-[8px]">
|
||||||
|
Payment Failed
|
||||||
|
</Heading>
|
||||||
|
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
|
||||||
|
Hello {userName}, we were unable to process your payment. Please
|
||||||
|
update your payment method to avoid service interruption.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Invoice Details Card */}
|
||||||
|
<Section className="border border-solid border-[#e4e4e7] rounded-lg overflow-hidden mb-[24px]">
|
||||||
|
<Row className="bg-[#fafafa]">
|
||||||
|
<Column className="px-[20px] py-[14px] w-[50%]">
|
||||||
|
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
|
||||||
|
Invoice No.
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[#09090b] text-[14px] font-semibold m-0 mt-[4px]">
|
||||||
|
{invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
</Column>
|
||||||
|
<Column className="px-[20px] py-[14px] w-[50%]">
|
||||||
|
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
|
||||||
|
Date
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[#09090b] text-[14px] font-semibold m-0 mt-[4px]">
|
||||||
|
{date}
|
||||||
|
</Text>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
<Hr className="border-[#e4e4e7] m-0" />
|
||||||
|
<Row>
|
||||||
|
<Column className="px-[20px] py-[14px]">
|
||||||
|
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
|
||||||
|
Amount Due
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[#09090b] text-[20px] font-bold m-0 mt-[4px]">
|
||||||
|
{amountDue}{" "}
|
||||||
|
<span className="text-[#71717a] text-[12px] font-normal uppercase">
|
||||||
|
{currency}
|
||||||
|
</span>
|
||||||
|
</Text>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Status Badge */}
|
||||||
|
<Section className="mb-[24px]">
|
||||||
|
<Row>
|
||||||
|
<Column>
|
||||||
|
<div
|
||||||
|
className="inline-block rounded-full bg-[#fee2e2] px-[12px] py-[6px]"
|
||||||
|
style={{ display: "inline-block" }}
|
||||||
|
>
|
||||||
|
<Text className="text-[#dc2626] text-[12px] font-semibold m-0">
|
||||||
|
Payment Failed
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
|
<Section className="bg-[#fefce8] border border-solid border-[#fef08a] rounded-lg px-[20px] py-[16px] mb-[24px]">
|
||||||
|
<Text className="text-[#854d0e] text-[13px] leading-[20px] m-0">
|
||||||
|
If the payment issue is not resolved, your servers will be
|
||||||
|
deactivated. Please update your payment method as soon as
|
||||||
|
possible.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* CTA Button */}
|
||||||
|
<Section className="text-center mb-[24px]">
|
||||||
|
<Button
|
||||||
|
href={hostedInvoiceUrl}
|
||||||
|
className="bg-[#dc2626] rounded-lg text-white text-[14px] font-semibold no-underline text-center px-[24px] py-[12px]"
|
||||||
|
>
|
||||||
|
Update Payment Method
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Section className="bg-[#fafafa] rounded-b-xl px-[40px] py-[24px] text-center border-t border-solid border-[#e4e4e7]">
|
||||||
|
<Text className="text-[#a1a1aa] text-[12px] leading-[18px] m-0">
|
||||||
|
This is an automated email from{" "}
|
||||||
|
<Link
|
||||||
|
href="https://dokploy.com"
|
||||||
|
className="text-[#71717a] underline"
|
||||||
|
>
|
||||||
|
Dokploy Cloud
|
||||||
|
</Link>
|
||||||
|
. If you have any questions about your billing, please contact
|
||||||
|
our{" "}
|
||||||
|
<Link
|
||||||
|
href="https://discord.gg/2tBnJ3jDJc"
|
||||||
|
className="text-[#71717a] underline"
|
||||||
|
>
|
||||||
|
support team
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PaymentFailedEmail;
|
||||||
104
packages/server/src/emails/emails/verify-email.tsx
Normal file
104
packages/server/src/emails/emails/verify-email.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Html,
|
||||||
|
Img,
|
||||||
|
Link,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Tailwind,
|
||||||
|
Text,
|
||||||
|
} from "@react-email/components";
|
||||||
|
|
||||||
|
export type TemplateProps = {
|
||||||
|
userName: string;
|
||||||
|
verificationUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VerifyEmailTemplate = ({
|
||||||
|
userName = "User",
|
||||||
|
verificationUrl = "https://app.dokploy.com/verify",
|
||||||
|
}: TemplateProps) => {
|
||||||
|
const previewText = "Verify your email address to get started with Dokploy";
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<Tailwind
|
||||||
|
config={{
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: "#007291",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
|
||||||
|
<Container className="my-[40px] mx-auto max-w-[520px]">
|
||||||
|
{/* Header */}
|
||||||
|
<Section className="bg-[#09090b] rounded-t-xl px-[40px] py-[32px] text-center">
|
||||||
|
<Img
|
||||||
|
src="https://raw.githubusercontent.com/Dokploy/website/refs/heads/main/apps/docs/public/logo-dokploy-blackpng.png"
|
||||||
|
width="190"
|
||||||
|
height="120"
|
||||||
|
alt="Dokploy"
|
||||||
|
className="my-0 mx-auto"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<Section className="bg-white px-[40px] py-[32px]">
|
||||||
|
<Heading className="text-[#09090b] text-[22px] font-semibold m-0 mb-[8px]">
|
||||||
|
Verify Your Email
|
||||||
|
</Heading>
|
||||||
|
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
|
||||||
|
Hello {userName}, thank you for signing up for Dokploy. Please
|
||||||
|
verify your email address to activate your account.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* CTA Button */}
|
||||||
|
<Section className="text-center mb-[24px]">
|
||||||
|
<Button
|
||||||
|
href={verificationUrl}
|
||||||
|
className="bg-[#09090b] rounded-lg text-white text-[14px] font-semibold no-underline text-center px-[24px] py-[12px]"
|
||||||
|
>
|
||||||
|
Verify Email Address
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Text className="text-[#a1a1aa] text-[13px] leading-[20px] m-0 text-center mb-[16px]">
|
||||||
|
If the button above doesn't work, copy and paste the following
|
||||||
|
link into your browser:
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[#71717a] text-[12px] leading-[18px] m-0 text-center break-all">
|
||||||
|
{verificationUrl}
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Section className="bg-[#fafafa] rounded-b-xl px-[40px] py-[24px] text-center border-t border-solid border-[#e4e4e7]">
|
||||||
|
<Text className="text-[#a1a1aa] text-[12px] leading-[18px] m-0">
|
||||||
|
This is an automated email from{" "}
|
||||||
|
<Link
|
||||||
|
href="https://dokploy.com"
|
||||||
|
className="text-[#71717a] underline"
|
||||||
|
>
|
||||||
|
Dokploy Cloud
|
||||||
|
</Link>
|
||||||
|
. If you didn't create an account, you can safely ignore this
|
||||||
|
email.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VerifyEmailTemplate;
|
||||||
@@ -21,7 +21,10 @@ import {
|
|||||||
updateWebServerSettings,
|
updateWebServerSettings,
|
||||||
} from "../services/web-server-settings";
|
} from "../services/web-server-settings";
|
||||||
import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
|
import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
|
||||||
import { sendEmail } from "../verification/send-verification-email";
|
import {
|
||||||
|
sendEmail,
|
||||||
|
sendVerificationEmail,
|
||||||
|
} from "../verification/send-verification-email";
|
||||||
import { getPublicIpWithFallback } from "../wss/utils";
|
import { getPublicIpWithFallback } from "../wss/utils";
|
||||||
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
|
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
|
||||||
|
|
||||||
@@ -106,14 +109,13 @@ const { handler, api } = betterAuth({
|
|||||||
emailVerification: {
|
emailVerification: {
|
||||||
sendOnSignUp: true,
|
sendOnSignUp: true,
|
||||||
autoSignInAfterVerification: true,
|
autoSignInAfterVerification: true,
|
||||||
|
sendOnSignIn: true,
|
||||||
sendVerificationEmail: async ({ user, url }) => {
|
sendVerificationEmail: async ({ user, url }) => {
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
await sendEmail({
|
await sendVerificationEmail({
|
||||||
|
userName: user.name || "User",
|
||||||
email: user.email,
|
email: user.email,
|
||||||
subject: "Verify your email",
|
verificationUrl: url,
|
||||||
text: `
|
|
||||||
<p>Click the link to verify your email: <a href="${url}">Verify Email</a></p>
|
|
||||||
`,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -196,7 +198,7 @@ const { handler, api } = betterAuth({
|
|||||||
where: eq(schema.member.role, "owner"),
|
where: eq(schema.member.role, "owner"),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!IS_CLOUD) {
|
if (!IS_CLOUD && !isAdminPresent) {
|
||||||
await updateWebServerSettings({
|
await updateWebServerSettings({
|
||||||
serverIp: await getPublicIpWithFallback(),
|
serverIp: await getPublicIpWithFallback(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -108,22 +108,45 @@ export const suggestVariants = async ({
|
|||||||
ip = "127.0.0.1";
|
ip = "127.0.0.1";
|
||||||
}
|
}
|
||||||
|
|
||||||
const suggestionsSchema = z.object({
|
const fullSchema = z.object({
|
||||||
suggestions: z.array(
|
suggestions: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
shortDescription: z.string(),
|
shortDescription: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
|
dockerCompose: z.string(),
|
||||||
|
envVariables: z.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
domains: z.array(
|
||||||
|
z.object({
|
||||||
|
host: z.string(),
|
||||||
|
port: z.number(),
|
||||||
|
serviceName: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
configFiles: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
content: z.string(),
|
||||||
|
filePath: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
const suggestionsResult = await generateText({
|
|
||||||
|
const result = await generateText({
|
||||||
model,
|
model,
|
||||||
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
|
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
|
||||||
output: Output.object({ schema: suggestionsSchema }),
|
output: Output.object({ schema: fullSchema }),
|
||||||
prompt: `
|
prompt: `
|
||||||
Act as advanced DevOps engineer and analyze the user's request to determine the appropriate suggestions (up to 3 items).
|
Act as advanced DevOps engineer. Analyze the user's request and generate up to 3 deployment suggestions, each with a complete docker compose configuration.
|
||||||
|
|
||||||
CRITICAL - Read the user's request carefully and follow the appropriate strategy:
|
CRITICAL - Read the user's request carefully and follow the appropriate strategy:
|
||||||
|
|
||||||
@@ -139,163 +162,94 @@ export const suggestVariants = async ({
|
|||||||
- Example: For "personal blog" → "WordPress", "Ghost", "Hugo with Nginx"
|
- Example: For "personal blog" → "WordPress", "Ghost", "Hugo with Nginx"
|
||||||
- The name should be the actual project name
|
- The name should be the actual project name
|
||||||
|
|
||||||
Return your response as a JSON object with the following structure:
|
Return your response as a JSON object with this structure:
|
||||||
{
|
{
|
||||||
"suggestions": [
|
"suggestions": [
|
||||||
{
|
{
|
||||||
"id": "project-or-variant-slug",
|
"id": "project-or-variant-slug",
|
||||||
"name": "Project Name or Variant Name",
|
"name": "Project Name or Variant Name",
|
||||||
"shortDescription": "Brief one-line description",
|
"shortDescription": "Brief one-line description",
|
||||||
"description": "Detailed description"
|
"description": "Detailed description of the project/variant",
|
||||||
|
"dockerCompose": "yaml string here",
|
||||||
|
"envVariables": [{"name": "VAR_NAME", "value": "example_value"}],
|
||||||
|
"domains": [{"host": "domain.com", "port": 3000, "serviceName": "service"}],
|
||||||
|
"configFiles": [{"content": "file content", "filePath": "path/to/file"}]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
Important rules for the response:
|
Suggestion Rules:
|
||||||
1. Use slug format for the id field (lowercase, hyphenated)
|
1. Use slug format for the id field (lowercase, hyphenated)
|
||||||
2. Determine which strategy to use based on whether the user specified a particular application or described a general need
|
2. The description field should ONLY contain plain text — no code snippets or installation instructions
|
||||||
3. For Strategy A (specific app): The name must include the app name and describe the variant configuration
|
3. The shortDescription should be a single-line summary focusing on key technologies or differentiators
|
||||||
4. For Strategy B (general need): The name should be the actual project/tool name that fulfills the need
|
4. All suggestions should be installable in docker and have docker compose support
|
||||||
5. The description field should ONLY contain a plain text description of the project or variant, its features, and use cases
|
5. Provide variety in your suggestions - different complexity levels, tech stacks, or approaches
|
||||||
6. Do NOT include any code snippets, configuration examples, or installation instructions in the description
|
|
||||||
7. The shortDescription should be a single-line summary focusing on key technologies or differentiators
|
|
||||||
8. All suggestions should be installable in docker and have docker compose support
|
|
||||||
9. Provide variety in your suggestions - different complexity levels, tech stacks, or approaches
|
|
||||||
|
|
||||||
User wants to create a new project with the following details:
|
Docker Compose Rules:
|
||||||
|
1. Use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker-compose.yml
|
||||||
|
2. Use complex values for passwords/secrets variables
|
||||||
|
3. Don't set container_name field in services
|
||||||
|
4. Don't set version field in the docker compose
|
||||||
|
5. Don't set ports like 'ports: 3000:3000', use 'ports: "3000"' instead
|
||||||
|
6. If a service depends on a database or other service, INCLUDE that service in the docker-compose
|
||||||
|
7. Make sure all required services are defined in the docker-compose
|
||||||
|
|
||||||
${input}
|
Docker Image Rules (CRITICAL):
|
||||||
|
1. ALWAYS use 'image:' field, NEVER use 'build:' field
|
||||||
|
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
|
||||||
|
3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io)
|
||||||
|
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.)
|
||||||
|
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
|
||||||
|
6. Examples of correct image usage:
|
||||||
|
- image: sendingtk/chatwoot:develop
|
||||||
|
- image: postgres:16-alpine
|
||||||
|
- image: redis:7-alpine
|
||||||
|
7. Examples of INCORRECT usage (DO NOT USE):
|
||||||
|
- build: .
|
||||||
|
- build: ./app
|
||||||
|
- build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
|
||||||
|
Volume Mounting and Configuration Rules:
|
||||||
|
1. DO NOT create configuration files unless the service CANNOT work without them
|
||||||
|
2. Most services can work with just environment variables - USE THEM FIRST
|
||||||
|
3. If and ONLY IF a config file is absolutely required:
|
||||||
|
- Keep it minimal with only critical settings
|
||||||
|
- Use "../files/" prefix for all mounts
|
||||||
|
- Format: "../files/folder:/container/path"
|
||||||
|
4. DO NOT add configuration files for default configs, env-configurable settings, or proxy/routing configs
|
||||||
|
|
||||||
|
Environment Variables Rules:
|
||||||
|
1. For the envVariables array, provide ACTUAL example values, not placeholders
|
||||||
|
2. Use realistic example values (e.g., "admin@example.com" for emails, "mypassword123" for passwords)
|
||||||
|
3. DO NOT use \${VARIABLE_NAME-default} syntax in the envVariables values
|
||||||
|
4. ONLY include environment variables that are actually used in the docker-compose
|
||||||
|
5. Every environment variable referenced in the docker-compose MUST have a corresponding entry in envVariables
|
||||||
|
|
||||||
|
Domain Rules - For each service that needs to be exposed to the internet:
|
||||||
|
1. Define a domain with:
|
||||||
|
- host: {service-name}-{random-3-chars-hex}-${ip ? ip.replaceAll(".", "-") : ""}.traefik.me
|
||||||
|
- port: the internal port the service runs on
|
||||||
|
- serviceName: the name of the service in the docker-compose
|
||||||
|
2. Make sure the service is properly configured to work with the specified port
|
||||||
|
|
||||||
|
User's request: ${input}
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
const object = suggestionsResult.output as SuggestionsOutput | undefined;
|
|
||||||
|
|
||||||
if (object?.suggestions?.length) {
|
const output = result.output as
|
||||||
const dockerSchema = z.object({
|
| { suggestions: (SuggestionItem & DockerOutput)[] }
|
||||||
dockerCompose: z.string(),
|
| undefined;
|
||||||
envVariables: z.array(
|
|
||||||
z.object({
|
if (!output?.suggestions?.length) {
|
||||||
name: z.string(),
|
throw new TRPCError({
|
||||||
value: z.string(),
|
code: "NOT_FOUND",
|
||||||
}),
|
message: "No suggestions found",
|
||||||
),
|
|
||||||
domains: z.array(
|
|
||||||
z.object({
|
|
||||||
host: z.string(),
|
|
||||||
port: z.number(),
|
|
||||||
serviceName: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
configFiles: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
content: z.string(),
|
|
||||||
filePath: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
});
|
});
|
||||||
const result = [];
|
|
||||||
for (const suggestion of object.suggestions) {
|
|
||||||
try {
|
|
||||||
const dockerResult = await generateText({
|
|
||||||
model,
|
|
||||||
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
|
|
||||||
output: Output.object({ schema: dockerSchema }),
|
|
||||||
prompt: `
|
|
||||||
Act as advanced DevOps engineer and generate docker compose with environment variables and domain configurations needed to install the following project.
|
|
||||||
|
|
||||||
Return your response as a JSON object with this structure:
|
|
||||||
{
|
|
||||||
"dockerCompose": "yaml string here",
|
|
||||||
"envVariables": [{"name": "VAR_NAME", "value": "example_value"}],
|
|
||||||
"domains": [{"host": "domain.com", "port": 3000, "serviceName": "service"}],
|
|
||||||
"configFiles": [{"content": "file content", "filePath": "path/to/file"}]
|
|
||||||
}
|
|
||||||
|
|
||||||
Note: configFiles is optional - only include it if configuration files are absolutely required.
|
|
||||||
|
|
||||||
Follow these rules:
|
|
||||||
|
|
||||||
Docker Compose Rules:
|
|
||||||
1. Use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker-compose.yml
|
|
||||||
2. Use complex values for passwords/secrets variables
|
|
||||||
3. Don't set container_name field in services
|
|
||||||
4. Don't set version field in the docker compose
|
|
||||||
5. Don't set ports like 'ports: 3000:3000', use 'ports: "3000"' instead
|
|
||||||
6. If a service depends on a database or other service, INCLUDE that service in the docker-compose
|
|
||||||
7. Make sure all required services are defined in the docker-compose
|
|
||||||
|
|
||||||
Docker Image Rules (CRITICAL):
|
|
||||||
1. ALWAYS use 'image:' field, NEVER use 'build:' field
|
|
||||||
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
|
|
||||||
3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io)
|
|
||||||
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.)
|
|
||||||
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
|
|
||||||
6. Examples of correct image usage:
|
|
||||||
- image: sendingtk/chatwoot:develop
|
|
||||||
- image: postgres:16-alpine
|
|
||||||
- image: redis:7-alpine
|
|
||||||
- image: chatwoot/chatwoot:latest
|
|
||||||
7. Examples of INCORRECT usage (DO NOT USE):
|
|
||||||
- build: .
|
|
||||||
- build: ./app
|
|
||||||
- build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
|
|
||||||
Volume Mounting and Configuration Rules:
|
|
||||||
1. DO NOT create configuration files unless the service CANNOT work without them
|
|
||||||
2. Most services can work with just environment variables - USE THEM FIRST
|
|
||||||
3. Ask yourself: "Can this be configured with an environment variable instead?"
|
|
||||||
4. If and ONLY IF a config file is absolutely required:
|
|
||||||
- Keep it minimal with only critical settings
|
|
||||||
- Use "../files/" prefix for all mounts
|
|
||||||
- Format: "../files/folder:/container/path"
|
|
||||||
5. DO NOT add configuration files for:
|
|
||||||
- Default configurations that work out of the box
|
|
||||||
- Settings that can be handled by environment variables
|
|
||||||
- Proxy or routing configurations (these are handled elsewhere)
|
|
||||||
|
|
||||||
Environment Variables Rules:
|
|
||||||
1. For the envVariables array, provide ACTUAL example values, not placeholders
|
|
||||||
2. Use realistic example values (e.g., "admin@example.com" for emails, "mypassword123" for passwords)
|
|
||||||
3. DO NOT use \${VARIABLE_NAME-default} syntax in the envVariables values
|
|
||||||
4. ONLY include environment variables that are actually used in the docker-compose
|
|
||||||
5. Every environment variable referenced in the docker-compose MUST have a corresponding entry in envVariables
|
|
||||||
6. Do not include environment variables for services that don't exist in the docker-compose
|
|
||||||
|
|
||||||
For each service that needs to be exposed to the internet:
|
|
||||||
1. Define a domain configuration with:
|
|
||||||
- host: the domain name for the service in format: {service-name}-{random-3-chars-hex}-${ip ? ip.replaceAll(".", "-") : ""}.traefik.me
|
|
||||||
- port: the internal port the service runs on
|
|
||||||
- serviceName: the name of the service in the docker-compose
|
|
||||||
2. Make sure the service is properly configured to work with the specified port
|
|
||||||
|
|
||||||
User's original request: ${input}
|
|
||||||
|
|
||||||
Project details:
|
|
||||||
${suggestion?.description}
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
const docker = dockerResult.output as DockerOutput | undefined;
|
|
||||||
if (docker?.dockerCompose) {
|
|
||||||
result.push({
|
|
||||||
...suggestion,
|
|
||||||
...docker,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error in docker compose generation:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new TRPCError({
|
return output.suggestions.filter((s) => s.dockerCompose);
|
||||||
code: "NOT_FOUND",
|
|
||||||
message: "No suggestions found",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error in suggestVariants:", error);
|
console.error("Error in suggestVariants:", error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -440,17 +440,16 @@ export const removeCompose = async (
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const command = `
|
const command = `
|
||||||
docker network disconnect ${compose.appName} dokploy-traefik;
|
docker network disconnect ${compose.appName} dokploy-traefik;
|
||||||
cd ${projectPath} && env -i PATH="$PATH" docker compose -p ${compose.appName} down ${
|
env -i PATH="$PATH" docker compose -p ${compose.appName} down ${
|
||||||
deleteVolumes ? "--volumes" : ""
|
deleteVolumes ? "--volumes" : ""
|
||||||
} && rm -rf ${projectPath}`;
|
};
|
||||||
|
rm -rf ${projectPath}`;
|
||||||
|
|
||||||
if (compose.serverId) {
|
if (compose.serverId) {
|
||||||
await execAsyncRemote(compose.serverId, command);
|
await execAsyncRemote(compose.serverId, command);
|
||||||
} else {
|
} else {
|
||||||
await execAsync(command, {
|
await execAsync(command);
|
||||||
cwd: projectPath,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -354,21 +354,121 @@ export const getContainersByAppLabel = async (
|
|||||||
return [];
|
return [];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const containerRestart = async (containerId: string) => {
|
export const getContainerLogs = async (
|
||||||
try {
|
appNameOrId: string,
|
||||||
const { stdout, stderr } = await execAsync(
|
tail = 100,
|
||||||
`docker container restart ${containerId}`,
|
since = "all",
|
||||||
|
search?: string,
|
||||||
|
serverId?: string | null,
|
||||||
|
useContainerIdDirectly = false,
|
||||||
|
): Promise<string> => {
|
||||||
|
const exec = (cmd: string) =>
|
||||||
|
serverId ? execAsyncRemote(serverId, cmd) : execAsync(cmd);
|
||||||
|
|
||||||
|
let target = appNameOrId;
|
||||||
|
let isService = false;
|
||||||
|
|
||||||
|
if (!useContainerIdDirectly) {
|
||||||
|
// Find the real container ID by appName filter
|
||||||
|
const findResult = await exec(
|
||||||
|
`docker ps -q --filter "name=^${appNameOrId}" | head -1`,
|
||||||
);
|
);
|
||||||
|
const containerId = findResult.stdout.trim();
|
||||||
|
|
||||||
if (stderr) {
|
if (!containerId) {
|
||||||
console.error(`Error: ${stderr}`);
|
// Fallback: try as a swarm service
|
||||||
return;
|
const svcResult = await exec(
|
||||||
|
`docker service ls -q --filter "name=${appNameOrId}" | head -1`,
|
||||||
|
);
|
||||||
|
const serviceId = svcResult.stdout.trim();
|
||||||
|
if (!serviceId) {
|
||||||
|
throw new Error(`No container or service found for: ${appNameOrId}`);
|
||||||
|
}
|
||||||
|
isService = true;
|
||||||
|
} else {
|
||||||
|
target = containerId;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const config = JSON.parse(stdout);
|
const sinceFlag = since === "all" ? "" : `--since ${since}`;
|
||||||
|
const baseCommand = isService
|
||||||
|
? `docker service logs --timestamps --raw --tail ${tail} ${sinceFlag} ${target}`
|
||||||
|
: `docker container logs --timestamps --tail ${tail} ${sinceFlag} ${target}`;
|
||||||
|
|
||||||
return config;
|
const escapedSearch = search?.replace(/'/g, "'\\''") ?? "";
|
||||||
} catch {}
|
const command = search
|
||||||
|
? `${baseCommand} 2>&1 | grep -iF '${escapedSearch}'`
|
||||||
|
: `${baseCommand} 2>&1`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await exec(command);
|
||||||
|
return result.stdout;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (
|
||||||
|
error &&
|
||||||
|
typeof error === "object" &&
|
||||||
|
"stdout" in error &&
|
||||||
|
typeof (error as { stdout: string }).stdout === "string" &&
|
||||||
|
(error as { stdout: string }).stdout.length > 0
|
||||||
|
) {
|
||||||
|
return (error as { stdout: string }).stdout;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const containerRestart = async (
|
||||||
|
containerId: string,
|
||||||
|
serverId?: string,
|
||||||
|
) => {
|
||||||
|
const command = `docker container restart ${containerId}`;
|
||||||
|
const { stderr } = serverId
|
||||||
|
? await execAsyncRemote(serverId, command)
|
||||||
|
: await execAsync(command);
|
||||||
|
|
||||||
|
if (stderr) {
|
||||||
|
console.error(`Error: ${stderr}`);
|
||||||
|
throw new Error(stderr);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const containerStart = async (
|
||||||
|
containerId: string,
|
||||||
|
serverId?: string,
|
||||||
|
) => {
|
||||||
|
const command = `docker container start ${containerId}`;
|
||||||
|
const { stderr } = serverId
|
||||||
|
? await execAsyncRemote(serverId, command)
|
||||||
|
: await execAsync(command);
|
||||||
|
|
||||||
|
if (stderr) {
|
||||||
|
console.error(`Error: ${stderr}`);
|
||||||
|
throw new Error(stderr);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const containerStop = async (containerId: string, serverId?: string) => {
|
||||||
|
const command = `docker container stop ${containerId}`;
|
||||||
|
const { stderr } = serverId
|
||||||
|
? await execAsyncRemote(serverId, command)
|
||||||
|
: await execAsync(command);
|
||||||
|
|
||||||
|
if (stderr) {
|
||||||
|
console.error(`Error: ${stderr}`);
|
||||||
|
throw new Error(stderr);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const containerKill = async (containerId: string, serverId?: string) => {
|
||||||
|
const command = `docker container kill ${containerId}`;
|
||||||
|
const { stderr } = serverId
|
||||||
|
? await execAsyncRemote(serverId, command)
|
||||||
|
: await execAsync(command);
|
||||||
|
|
||||||
|
if (stderr) {
|
||||||
|
console.error(`Error: ${stderr}`);
|
||||||
|
throw new Error(stderr);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const containerRemove = async (
|
export const containerRemove = async (
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export function getProviderName(apiUrl: string) {
|
|||||||
if (apiUrl.includes(":11434") || apiUrl.includes("ollama")) return "ollama";
|
if (apiUrl.includes(":11434") || apiUrl.includes("ollama")) return "ollama";
|
||||||
if (apiUrl.includes("api.deepinfra.com")) return "deepinfra";
|
if (apiUrl.includes("api.deepinfra.com")) return "deepinfra";
|
||||||
if (apiUrl.includes("generativelanguage.googleapis.com")) return "gemini";
|
if (apiUrl.includes("generativelanguage.googleapis.com")) return "gemini";
|
||||||
|
if (apiUrl.includes("openrouter.ai")) return "openrouter";
|
||||||
|
if (apiUrl.includes("api.z.ai")) return "zai";
|
||||||
|
if (apiUrl.includes("api.minimax.io")) return "minimax";
|
||||||
return "custom";
|
return "custom";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +90,30 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
|
|||||||
Authorization: `Bearer ${config.apiKey}`,
|
Authorization: `Bearer ${config.apiKey}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
case "openrouter":
|
||||||
|
return createOpenAICompatible({
|
||||||
|
name: "openrouter",
|
||||||
|
baseURL: config.apiUrl,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${config.apiKey}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
case "zai":
|
||||||
|
return createOpenAICompatible({
|
||||||
|
name: "zai",
|
||||||
|
baseURL: config.apiUrl,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${config.apiKey}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
case "minimax":
|
||||||
|
return createOpenAICompatible({
|
||||||
|
name: "minimax",
|
||||||
|
baseURL: config.apiUrl,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${config.apiKey}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
case "custom":
|
case "custom":
|
||||||
return createOpenAICompatible({
|
return createOpenAICompatible({
|
||||||
name: "custom",
|
name: "custom",
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => {
|
|||||||
const envFilePath = join(dirname(composeFilePath), ".env");
|
const envFilePath = join(dirname(composeFilePath), ".env");
|
||||||
|
|
||||||
let envContent = `APP_NAME=${appName}\n`;
|
let envContent = `APP_NAME=${appName}\n`;
|
||||||
|
envContent += `COMPOSE_PROJECT_NAME=${appName}\n`;
|
||||||
envContent += env || "";
|
envContent += env || "";
|
||||||
if (!envContent.includes("DOCKER_CONFIG")) {
|
if (!envContent.includes("DOCKER_CONFIG")) {
|
||||||
envContent += "\nDOCKER_CONFIG=/root/.docker";
|
envContent += "\nDOCKER_CONFIG=/root/.docker";
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const sendEmailNotification = async (
|
|||||||
connection: typeof email.$inferInsert,
|
connection: typeof email.$inferInsert,
|
||||||
subject: string,
|
subject: string,
|
||||||
htmlContent: string,
|
htmlContent: string,
|
||||||
|
attachments?: { filename: string; content: Buffer }[],
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
@@ -41,6 +42,7 @@ export const sendEmailNotification = async (
|
|||||||
subject,
|
subject,
|
||||||
html: htmlContent,
|
html: htmlContent,
|
||||||
textEncoding: "base64",
|
textEncoding: "base64",
|
||||||
|
attachments,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
|
|||||||
@@ -151,16 +151,18 @@ export const createRouterConfig = async (
|
|||||||
routerConfig.middlewares?.push("redirect-to-https");
|
routerConfig.middlewares?.push("redirect-to-https");
|
||||||
} else {
|
} else {
|
||||||
// Add path rewriting middleware if needed
|
// Add path rewriting middleware if needed
|
||||||
if (internalPath && internalPath !== "/" && internalPath !== path) {
|
// stripPrefix must come before addPrefix so Traefik strips the
|
||||||
const pathMiddleware = `addprefix-${appName}-${uniqueConfigKey}`;
|
// public path first, then prepends the internal path.
|
||||||
routerConfig.middlewares?.push(pathMiddleware);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stripPath && path && path !== "/") {
|
if (stripPath && path && path !== "/") {
|
||||||
const stripMiddleware = `stripprefix-${appName}-${uniqueConfigKey}`;
|
const stripMiddleware = `stripprefix-${appName}-${uniqueConfigKey}`;
|
||||||
routerConfig.middlewares?.push(stripMiddleware);
|
routerConfig.middlewares?.push(stripMiddleware);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (internalPath && internalPath !== "/" && internalPath !== path) {
|
||||||
|
const pathMiddleware = `addprefix-${appName}-${uniqueConfigKey}`;
|
||||||
|
routerConfig.middlewares?.push(pathMiddleware);
|
||||||
|
}
|
||||||
|
|
||||||
// redirects - skip for preview deployments as wildcard subdomains
|
// redirects - skip for preview deployments as wildcard subdomains
|
||||||
// should not inherit parent redirect rules (e.g., www-redirect)
|
// should not inherit parent redirect rules (e.g., www-redirect)
|
||||||
if (domain.domainType !== "preview") {
|
if (domain.domainType !== "preview") {
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
|
import { renderAsync } from "@react-email/components";
|
||||||
|
import VerifyEmailTemplate from "../emails/emails/verify-email";
|
||||||
import { sendEmailNotification } from "../utils/notifications/utils";
|
import { sendEmailNotification } from "../utils/notifications/utils";
|
||||||
|
|
||||||
export const sendEmail = async ({
|
export const sendEmail = async ({
|
||||||
email,
|
email,
|
||||||
subject,
|
subject,
|
||||||
text,
|
text,
|
||||||
|
attachments,
|
||||||
}: {
|
}: {
|
||||||
email: string;
|
email: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
attachments?: { filename: string; content: Buffer }[];
|
||||||
}) => {
|
}) => {
|
||||||
await sendEmailNotification(
|
await sendEmailNotification(
|
||||||
{
|
{
|
||||||
@@ -19,7 +24,30 @@ export const sendEmail = async ({
|
|||||||
},
|
},
|
||||||
subject,
|
subject,
|
||||||
text,
|
text,
|
||||||
|
attachments,
|
||||||
);
|
);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const sendVerificationEmail = async ({
|
||||||
|
userName,
|
||||||
|
email,
|
||||||
|
verificationUrl,
|
||||||
|
}: {
|
||||||
|
userName: string;
|
||||||
|
email: string;
|
||||||
|
verificationUrl: string;
|
||||||
|
}) => {
|
||||||
|
const html = await renderAsync(
|
||||||
|
VerifyEmailTemplate({
|
||||||
|
userName: userName || "User",
|
||||||
|
verificationUrl,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await sendEmail({
|
||||||
|
email,
|
||||||
|
subject: "Verify your email",
|
||||||
|
text: html,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
30
pnpm-lock.yaml
generated
30
pnpm-lock.yaml
generated
@@ -176,6 +176,9 @@ importers:
|
|||||||
'@radix-ui/react-collapsible':
|
'@radix-ui/react-collapsible':
|
||||||
specifier: ^1.1.11
|
specifier: ^1.1.11
|
||||||
version: 1.1.12(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
version: 1.1.12(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||||
|
'@radix-ui/react-context-menu':
|
||||||
|
specifier: ^2.2.16
|
||||||
|
version: 2.2.16(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||||
'@radix-ui/react-dialog':
|
'@radix-ui/react-dialog':
|
||||||
specifier: ^1.1.14
|
specifier: ^1.1.14
|
||||||
version: 1.1.15(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
version: 1.1.15(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||||
@@ -2801,6 +2804,19 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-context-menu@2.2.16':
|
||||||
|
resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': 18.3.5
|
||||||
|
'@types/react-dom': 18.3.0
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-context@1.0.0':
|
'@radix-ui/react-context@1.0.0':
|
||||||
resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==}
|
resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -10633,6 +10649,20 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 18.3.5
|
'@types/react': 18.3.5
|
||||||
|
|
||||||
|
'@radix-ui/react-context-menu@2.2.16(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@18.3.5)(react@18.2.0)
|
||||||
|
'@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||||
|
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.5)(react@18.2.0)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.5)(react@18.2.0)
|
||||||
|
react: 18.2.0
|
||||||
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 18.3.5
|
||||||
|
'@types/react-dom': 18.3.0
|
||||||
|
|
||||||
'@radix-ui/react-context@1.0.0(react@18.2.0)':
|
'@radix-ui/react-context@1.0.0(react@18.2.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.28.6
|
'@babel/runtime': 7.28.6
|
||||||
|
|||||||
Reference in New Issue
Block a user